From f75410fe3358b99dd7c81d7c0b4ea89fb8ce42ff Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Sat, 17 Jan 2026 11:40:56 -0800 Subject: [PATCH] docs: document all of the tui/tui2 and some other stuff Automated pass; details in docs/documentation-pass.md and docs/documentation-status.md. --- .../src/protocol/common.rs | 2 +- .../app-server-protocol/src/protocol/v2.rs | 10 +- .../app-server/src/bespoke_event_handling.rs | 67 +- .../app-server/src/codex_message_processor.rs | 150 +- codex-rs/cli/src/main.rs | 12 +- codex-rs/core/config.schema.json | 12 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/config/mod.rs | 6 +- codex-rs/core/src/config/profile.rs | 2 +- codex-rs/core/src/config/types.rs | 4 +- codex-rs/core/src/config_loader/mod.rs | 2 +- codex-rs/core/src/default_client.rs | 2 +- codex-rs/core/src/review_format.rs | 2 +- codex-rs/core/src/rollout/list.rs | 2 +- codex-rs/core/src/rollout/recorder.rs | 2 +- codex-rs/execpolicy/src/amend.rs | 2 +- codex-rs/file-search/src/lib.rs | 30 +- codex-rs/file-search/src/main.rs | 13 + codex-rs/keyring-store/src/lib.rs | 28 + codex-rs/mcp-types/src/lib.rs | 2 +- codex-rs/protocol/src/config_types.rs | 4 +- codex-rs/protocol/src/openai_models.rs | 2 +- codex-rs/protocol/src/protocol.rs | 2 +- codex-rs/responses-api-proxy/src/lib.rs | 2 +- codex-rs/rmcp-client/src/oauth.rs | 2 +- codex-rs/rmcp-client/src/rmcp_client.rs | 4 +- codex-rs/tui/src/additional_dirs.rs | 7 + codex-rs/tui/src/app.rs | 160 +- codex-rs/tui/src/app_backtrack.rs | 10 + codex-rs/tui/src/app_event.rs | 57 +- codex-rs/tui/src/app_event_sender.rs | 12 +- codex-rs/tui/src/ascii_animation.rs | 27 + codex-rs/tui/src/bin/md-events.rs | 6 + .../tui/src/bottom_pane/approval_overlay.rs | 168 +++ .../tui/src/bottom_pane/bottom_pane_view.rs | 38 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 218 ++- .../src/bottom_pane/chat_composer_history.rs | 91 +- codex-rs/tui/src/bottom_pane/command_popup.rs | 118 +- .../tui/src/bottom_pane/custom_prompt_view.rs | 60 +- .../bottom_pane/experimental_features_view.rs | 51 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 143 +- .../tui/src/bottom_pane/file_search_popup.rs | 111 +- codex-rs/tui/src/bottom_pane/footer.rs | 104 +- .../src/bottom_pane/list_selection_view.rs | 121 +- codex-rs/tui/src/bottom_pane/mod.rs | 185 ++- codex-rs/tui/src/bottom_pane/paste_burst.rs | 87 +- codex-rs/tui/src/bottom_pane/popup_consts.rs | 22 +- codex-rs/tui/src/bottom_pane/prompt_args.rs | 113 +- .../src/bottom_pane/queued_user_messages.rs | 46 +- codex-rs/tui/src/bottom_pane/scroll_state.rs | 61 +- .../src/bottom_pane/selection_popup_common.rs | 95 +- codex-rs/tui/src/bottom_pane/skill_popup.rs | 58 + codex-rs/tui/src/bottom_pane/textarea.rs | 120 +- .../src/bottom_pane/unified_exec_footer.rs | 18 + codex-rs/tui/src/chatwidget.rs | 536 ++++++- codex-rs/tui/src/chatwidget/agent.rs | 41 +- codex-rs/tui/src/chatwidget/interrupts.rs | 50 + codex-rs/tui/src/chatwidget/session_header.rs | 10 +- codex-rs/tui/src/chatwidget/tests.rs | 179 ++- codex-rs/tui/src/cli.rs | 55 +- codex-rs/tui/src/clipboard_paste.rs | 26 + codex-rs/tui/src/collab.rs | 16 + codex-rs/tui/src/color.rs | 9 + codex-rs/tui/src/custom_terminal.rs | 81 +- codex-rs/tui/src/diff_render.rs | 43 +- codex-rs/tui/src/exec_cell/mod.rs | 20 + codex-rs/tui/src/exec_cell/model.rs | 81 + codex-rs/tui/src/exec_cell/render.rs | 55 + codex-rs/tui/src/exec_command.rs | 9 +- codex-rs/tui/src/external_editor.rs | 12 + codex-rs/tui/src/file_search.rs | 11 + codex-rs/tui/src/frames.rs | 28 +- codex-rs/tui/src/history_cell.rs | 119 +- codex-rs/tui/src/insert_history.rs | 29 +- codex-rs/tui/src/key_hint.rs | 26 + codex-rs/tui/src/lib.rs | 102 +- codex-rs/tui/src/live_wrap.rs | 91 +- codex-rs/tui/src/main.rs | 10 + codex-rs/tui/src/markdown.rs | 12 + codex-rs/tui/src/markdown_render.rs | 82 + codex-rs/tui/src/markdown_render_tests.rs | 64 + codex-rs/tui/src/markdown_stream.rs | 19 + codex-rs/tui/src/model_migration.rs | 68 +- codex-rs/tui/src/notifications/mod.rs | 44 + codex-rs/tui/src/notifications/osc9.rs | 21 +- .../tui/src/notifications/windows_toast.rs | 36 + codex-rs/tui/src/onboarding/auth.rs | 81 +- .../onboarding/auth/headless_chatgpt_login.rs | 22 + codex-rs/tui/src/onboarding/mod.rs | 16 + .../tui/src/onboarding/onboarding_screen.rs | 58 + .../tui/src/onboarding/trust_directory.rs | 29 + codex-rs/tui/src/onboarding/welcome.rs | 17 + codex-rs/tui/src/oss_selection.rs | 76 +- codex-rs/tui/src/pager_overlay.rs | 52 +- .../tui/src/public_widgets/composer_input.rs | 22 +- codex-rs/tui/src/public_widgets/mod.rs | 6 + codex-rs/tui/src/render/highlight.rs | 39 +- codex-rs/tui/src/render/line_utils.rs | 18 + codex-rs/tui/src/render/mod.rs | 16 + codex-rs/tui/src/render/renderable.rs | 53 +- codex-rs/tui/src/resume_picker.rs | 146 +- codex-rs/tui/src/selection_list.rs | 9 + codex-rs/tui/src/session_log.rs | 24 + codex-rs/tui/src/shimmer.rs | 11 + codex-rs/tui/src/slash_command.rs | 27 +- codex-rs/tui/src/status/account.rs | 6 + codex-rs/tui/src/status/card.rs | 49 +- codex-rs/tui/src/status/format.rs | 23 + codex-rs/tui/src/status/helpers.rs | 36 + codex-rs/tui/src/status/mod.rs | 5 + codex-rs/tui/src/status/rate_limits.rs | 76 +- codex-rs/tui/src/status/tests.rs | 28 + codex-rs/tui/src/status_indicator_widget.rs | 45 +- codex-rs/tui/src/streaming/controller.rs | 49 +- codex-rs/tui/src/streaming/mod.rs | 35 + codex-rs/tui/src/style.rs | 9 + codex-rs/tui/src/terminal_palette.rs | 53 +- codex-rs/tui/src/test_backend.rs | 23 +- codex-rs/tui/src/text_formatting.rs | 40 +- codex-rs/tui/src/tooltips.rs | 40 +- codex-rs/tui/src/tui.rs | 148 +- codex-rs/tui/src/tui/event_stream.rs | 64 +- codex-rs/tui/src/tui/frame_rate_limiter.rs | 5 + codex-rs/tui/src/tui/frame_requester.rs | 19 +- codex-rs/tui/src/tui/job_control.rs | 16 +- codex-rs/tui/src/ui_consts.rs | 1 + codex-rs/tui/src/update_action.rs | 13 +- codex-rs/tui/src/update_prompt.rs | 32 + codex-rs/tui/src/updates.rs | 27 +- codex-rs/tui/src/wrapping.rs | 37 + codex-rs/tui/tests/all.rs | 10 +- codex-rs/tui/tests/suite/mod.rs | 6 +- .../tui/tests/suite/no_panic_on_startup.rs | 10 + codex-rs/tui/tests/suite/status_indicator.rs | 9 +- codex-rs/tui/tests/suite/vt100_history.rs | 20 +- codex-rs/tui/tests/suite/vt100_live_commit.rs | 6 + codex-rs/tui/tests/test_backend.rs | 7 + codex-rs/tui2/src/additional_dirs.rs | 7 + codex-rs/tui2/src/app.rs | 174 ++- codex-rs/tui2/src/app_backtrack.rs | 11 + codex-rs/tui2/src/app_event.rs | 51 +- codex-rs/tui2/src/app_event_sender.rs | 12 +- codex-rs/tui2/src/ascii_animation.rs | 27 + codex-rs/tui2/src/bin/md-events2.rs | 6 + .../tui2/src/bottom_pane/approval_overlay.rs | 168 +++ .../tui2/src/bottom_pane/bottom_pane_view.rs | 38 +- .../tui2/src/bottom_pane/chat_composer.rs | 213 ++- .../src/bottom_pane/chat_composer_history.rs | 91 +- .../tui2/src/bottom_pane/command_popup.rs | 118 +- .../src/bottom_pane/custom_prompt_view.rs | 60 +- .../tui2/src/bottom_pane/feedback_view.rs | 143 +- .../tui2/src/bottom_pane/file_search_popup.rs | 111 +- codex-rs/tui2/src/bottom_pane/footer.rs | 107 +- .../src/bottom_pane/list_selection_view.rs | 116 +- codex-rs/tui2/src/bottom_pane/mod.rs | 179 ++- codex-rs/tui2/src/bottom_pane/paste_burst.rs | 87 +- codex-rs/tui2/src/bottom_pane/popup_consts.rs | 22 +- codex-rs/tui2/src/bottom_pane/prompt_args.rs | 113 +- .../src/bottom_pane/queued_user_messages.rs | 46 +- codex-rs/tui2/src/bottom_pane/scroll_state.rs | 61 +- .../src/bottom_pane/selection_popup_common.rs | 90 +- codex-rs/tui2/src/bottom_pane/skill_popup.rs | 58 + codex-rs/tui2/src/bottom_pane/textarea.rs | 120 +- codex-rs/tui2/src/chatwidget.rs | 493 +++++- codex-rs/tui2/src/chatwidget/agent.rs | 41 +- codex-rs/tui2/src/chatwidget/interrupts.rs | 50 + .../tui2/src/chatwidget/session_header.rs | 10 +- codex-rs/tui2/src/chatwidget/tests.rs | 153 +- codex-rs/tui2/src/cli.rs | 66 +- codex-rs/tui2/src/clipboard_copy.rs | 20 + codex-rs/tui2/src/clipboard_paste.rs | 26 + codex-rs/tui2/src/collab.rs | 16 + codex-rs/tui2/src/color.rs | 9 + codex-rs/tui2/src/custom_terminal.rs | 81 +- codex-rs/tui2/src/diff_render.rs | 43 +- codex-rs/tui2/src/exec_cell/mod.rs | 20 + codex-rs/tui2/src/exec_cell/model.rs | 81 + codex-rs/tui2/src/exec_cell/render.rs | 55 + codex-rs/tui2/src/exec_command.rs | 9 +- codex-rs/tui2/src/file_search.rs | 11 + codex-rs/tui2/src/frames.rs | 28 +- codex-rs/tui2/src/history_cell.rs | 106 +- codex-rs/tui2/src/insert_history.rs | 18 +- codex-rs/tui2/src/key_hint.rs | 27 + codex-rs/tui2/src/lib.rs | 94 +- codex-rs/tui2/src/live_wrap.rs | 91 +- codex-rs/tui2/src/main.rs | 10 + codex-rs/tui2/src/markdown.rs | 12 + codex-rs/tui2/src/markdown_render.rs | 70 + codex-rs/tui2/src/markdown_render_tests.rs | 64 + codex-rs/tui2/src/markdown_stream.rs | 32 +- codex-rs/tui2/src/model_migration.rs | 68 +- codex-rs/tui2/src/notifications/mod.rs | 44 + codex-rs/tui2/src/notifications/osc9.rs | 21 +- .../tui2/src/notifications/windows_toast.rs | 36 + codex-rs/tui2/src/onboarding/auth.rs | 82 +- .../onboarding/auth/headless_chatgpt_login.rs | 22 + codex-rs/tui2/src/onboarding/mod.rs | 16 + .../tui2/src/onboarding/onboarding_screen.rs | 58 + .../tui2/src/onboarding/trust_directory.rs | 29 + codex-rs/tui2/src/onboarding/welcome.rs | 17 + codex-rs/tui2/src/oss_selection.rs | 76 +- codex-rs/tui2/src/pager_overlay.rs | 52 +- .../tui2/src/public_widgets/composer_input.rs | 22 +- codex-rs/tui2/src/public_widgets/mod.rs | 6 + codex-rs/tui2/src/render/highlight.rs | 39 +- codex-rs/tui2/src/render/line_utils.rs | 18 + codex-rs/tui2/src/render/mod.rs | 16 + codex-rs/tui2/src/render/renderable.rs | 53 +- codex-rs/tui2/src/resume_picker.rs | 146 +- codex-rs/tui2/src/selection_list.rs | 7 + codex-rs/tui2/src/session_log.rs | 24 + codex-rs/tui2/src/shimmer.rs | 11 + codex-rs/tui2/src/slash_command.rs | 27 +- codex-rs/tui2/src/status/account.rs | 6 + codex-rs/tui2/src/status/card.rs | 49 +- codex-rs/tui2/src/status/format.rs | 23 + codex-rs/tui2/src/status/helpers.rs | 36 + codex-rs/tui2/src/status/mod.rs | 5 + codex-rs/tui2/src/status/rate_limits.rs | 76 +- codex-rs/tui2/src/status/tests.rs | 30 + codex-rs/tui2/src/status_indicator_widget.rs | 47 +- codex-rs/tui2/src/streaming/controller.rs | 18 +- codex-rs/tui2/src/streaming/mod.rs | 15 + codex-rs/tui2/src/style.rs | 9 + codex-rs/tui2/src/terminal_palette.rs | 50 +- codex-rs/tui2/src/test_backend.rs | 23 +- codex-rs/tui2/src/text_formatting.rs | 40 +- codex-rs/tui2/src/tooltips.rs | 40 +- codex-rs/tui2/src/transcript_copy.rs | 57 +- codex-rs/tui2/src/transcript_copy_action.rs | 7 + codex-rs/tui2/src/transcript_copy_ui.rs | 45 +- codex-rs/tui2/src/transcript_scrollbar_ui.rs | 11 + codex-rs/tui2/src/transcript_view_cache.rs | 96 +- codex-rs/tui2/src/tui.rs | 108 +- codex-rs/tui2/src/tui/alt_screen_nesting.rs | 3 + codex-rs/tui2/src/tui/frame_rate_limiter.rs | 5 + codex-rs/tui2/src/tui/frame_requester.rs | 19 +- codex-rs/tui2/src/tui/job_control.rs | 16 +- codex-rs/tui2/src/ui_consts.rs | 1 + codex-rs/tui2/src/update_action.rs | 14 +- codex-rs/tui2/src/update_prompt.rs | 32 + codex-rs/tui2/src/updates.rs | 27 +- codex-rs/tui2/src/wrapping.rs | 37 + codex-rs/tui2/tests/all.rs | 10 +- codex-rs/tui2/tests/suite/mod.rs | 6 +- .../tui2/tests/suite/no_panic_on_startup.rs | 10 + codex-rs/tui2/tests/suite/status_indicator.rs | 9 +- codex-rs/tui2/tests/suite/vt100_history.rs | 20 +- .../tui2/tests/suite/vt100_live_commit.rs | 6 + codex-rs/tui2/tests/test_backend.rs | 7 + docs/documentation-pass.md | 424 ++++++ docs/documentation-status.md | 1319 +++++++++++++++++ 253 files changed, 12778 insertions(+), 1162 deletions(-) create mode 100644 docs/documentation-pass.md create mode 100644 docs/documentation-status.md diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bce2e48c97..0126a3baf5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -530,7 +530,7 @@ pub struct FuzzyFileSearchParams { pub cancellation_token: Option, } -/// Superset of [`codex_file_search::FileMatch`] +/// Superset of `codex_file_search::FileMatch`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] pub struct FuzzyFileSearchResult { pub root: String, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c0caa7ea32..56e55d613b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1065,12 +1065,12 @@ pub struct ThreadStartResponse { pub struct ThreadResumeParams { pub thread_id: String, - /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + /// UNSTABLE: FOR CODEX CLOUD - DO NOT USE. /// If specified, the thread will be resumed with the provided history /// instead of loaded from disk. pub history: Option>, - /// [UNSTABLE] Specify the rollout path to resume from. + /// UNSTABLE: Specify the rollout path to resume from. /// If specified, the thread_id param will be ignored. pub path: Option, @@ -1111,7 +1111,7 @@ pub struct ThreadResumeResponse { pub struct ThreadForkParams { pub thread_id: String, - /// [UNSTABLE] Specify the rollout path to fork from. + /// UNSTABLE: Specify the rollout path to fork from. /// If specified, the thread_id param will be ignored. pub path: Option, @@ -1390,7 +1390,7 @@ pub struct Thread { /// Unix timestamp (in seconds) when the thread was last updated. #[ts(type = "number")] pub updated_at: i64, - /// [UNSTABLE] Path to the thread on disk. + /// UNSTABLE: Path to the thread on disk. pub path: PathBuf, /// Working directory captured for the thread. pub cwd: PathBuf, @@ -2252,7 +2252,7 @@ pub struct FileChangeRequestApprovalParams { pub item_id: String, /// Optional explanatory reason (e.g. request for extra write access). pub reason: Option, - /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root + /// UNSTABLE: When set, the agent is asking the user to allow writes under this root /// for the remainder of the session (unclear if this is honored today). pub grant_root: Option, } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0b2520098d..c24cd1897d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1,3 +1,19 @@ +//! Bespoke event-to-protocol handling for the app server. +//! +//! This module sits on the boundary between core `Event` streams and the +//! app-server JSON-RPC protocol. It translates `codex-core` events into +//! protocol-specific notifications, and issues approval requests for actions +//! that require user consent (exec, patch, file changes, MCP elicitation). +//! +//! The implementation is deliberately "bespoke": it handles a handful of +//! protocol behaviors that haven't been fully encoded in core types yet, +//! including V1/V2 compatibility shims, rollout reconstruction for rollback, +//! and glue for approval request/response flows. +//! +//! Correctness depends on keeping turn summary state (`TurnSummaryStore`) in +//! sync with emitted notifications, especially for in-flight file changes and +//! errors, so downstream clients observe consistent turn lifecycles. + use crate::codex_message_processor::ApiVersion; use crate::codex_message_processor::PendingInterrupts; use crate::codex_message_processor::PendingRollbacks; @@ -90,8 +106,17 @@ use std::sync::Arc; use tokio::sync::oneshot; use tracing::error; +/// JSON payloads returned by approval requests. type JsonValue = serde_json::Value; +/// Dispatch a core `Event` into app-server side effects. +/// +/// This is the central event handler for the app server: +/// - Maps core events into app-server notifications. +/// - Spawns approval request/response tasks for exec and patch flows. +/// - Updates turn summary state used to emit terminal `TurnCompleted` events. +/// +/// V1/V2 protocol differences are handled inline by branching on `api_version`. #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( event: Event, @@ -1016,6 +1041,7 @@ pub(crate) async fn apply_bespoke_event_handling( } } +/// Emit a V2 turn-diff notification when supported by the API version. async fn handle_turn_diff( conversation_id: ThreadId, event_turn_id: &str, @@ -1035,6 +1061,7 @@ async fn handle_turn_diff( } } +/// Emit a V2 plan update notification for the provided plan change. async fn handle_turn_plan_update( conversation_id: ThreadId, event_turn_id: &str, @@ -1059,6 +1086,7 @@ async fn handle_turn_plan_update( } } +/// Send a `TurnCompleted` notification with the given status/error. async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, @@ -1080,6 +1108,7 @@ async fn emit_turn_completed_with_status( .await; } +/// Mark a file change item as completed and emit the completion notification. async fn complete_file_change_item( conversation_id: ThreadId, item_id: String, @@ -1112,6 +1141,7 @@ async fn complete_file_change_item( } #[allow(clippy::too_many_arguments)] +/// Emit a completed command execution item with the provided status. async fn complete_command_execution_item( conversation_id: ThreadId, turn_id: String, @@ -1144,6 +1174,7 @@ async fn complete_command_execution_item( .await; } +/// Emit a raw response item notification only for V2 clients. async fn maybe_emit_raw_response_item_completed( api_version: ApiVersion, conversation_id: ThreadId, @@ -1165,6 +1196,7 @@ async fn maybe_emit_raw_response_item_completed( .await; } +/// Drain the stored turn summary for a thread, returning a default if absent. async fn find_and_remove_turn_summary( conversation_id: ThreadId, turn_summary_store: &TurnSummaryStore, @@ -1173,6 +1205,7 @@ async fn find_and_remove_turn_summary( map.remove(&conversation_id).unwrap_or_default() } +/// Emit a completed/failed turn notification based on stored summary state. async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, @@ -1189,6 +1222,7 @@ async fn handle_turn_complete( emit_turn_completed_with_status(conversation_id, event_turn_id, status, error, outgoing).await; } +/// Emit an interrupted turn notification and clear stored summary state. async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, @@ -1207,6 +1241,7 @@ async fn handle_turn_interrupted( .await; } +/// Fail a pending rollback request with an invalid-request error. async fn handle_thread_rollback_failed( conversation_id: ThreadId, message: String, @@ -1232,6 +1267,7 @@ async fn handle_thread_rollback_failed( } } +/// Emit token-usage and rate-limit notifications from a token count event. async fn handle_token_count_event( conversation_id: ThreadId, turn_id: String, @@ -1260,6 +1296,7 @@ async fn handle_token_count_event( } } +/// Record the latest error in the turn summary store for completion handling. async fn handle_error( conversation_id: ThreadId, error: TurnError, @@ -1269,6 +1306,7 @@ async fn handle_error( map.entry(conversation_id).or_default().last_error = Some(error); } +/// Translate a patch approval response into a core `Op::PatchApproval`. async fn on_patch_approval_response( event_turn_id: String, receiver: oneshot::Receiver, @@ -1311,6 +1349,7 @@ async fn on_patch_approval_response( } } +/// Translate an exec approval response into a core `Op::ExecApproval`. async fn on_exec_approval_response( event_turn_id: String, receiver: oneshot::Receiver, @@ -1347,8 +1386,10 @@ async fn on_exec_approval_response( } } +/// Fallback message used when a review output is empty or malformed. const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; +/// Render review output into a human-readable string for thread items. fn render_review_output_text(output: &ReviewOutputEvent) -> String { let mut sections = Vec::new(); let explanation = output.overall_explanation.trim(); @@ -1369,6 +1410,7 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String { } } +/// Convert core file changes into protocol-facing update entries. fn convert_patch_changes(changes: &HashMap) -> Vec { let mut converted: Vec = changes .iter() @@ -1382,6 +1424,7 @@ fn convert_patch_changes(changes: &HashMap) -> Vec V2PatchChangeKind { match change { CoreFileChange::Add { .. } => V2PatchChangeKind::Add, @@ -1392,6 +1435,7 @@ fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind { } } +/// Build a displayable diff string for a file change entry. fn format_file_change_diff(change: &CoreFileChange) -> String { match change { CoreFileChange::Add { content } => content.clone(), @@ -1409,6 +1453,7 @@ fn format_file_change_diff(change: &CoreFileChange) -> String { } } +/// Map a file change approval decision into core review semantics. fn map_file_change_approval_decision( decision: FileChangeApprovalDecision, ) -> (ReviewDecision, Option) { @@ -1425,6 +1470,7 @@ fn map_file_change_approval_decision( } #[allow(clippy::too_many_arguments)] +/// Handle a file-change approval response and emit any completion events. async fn on_file_change_request_approval_response( event_turn_id: String, conversation_id: ThreadId, @@ -1483,6 +1529,7 @@ async fn on_file_change_request_approval_response( } #[allow(clippy::too_many_arguments)] +/// Handle a command-approval response and emit any completion events. async fn on_command_execution_request_approval_response( event_turn_id: String, conversation_id: ThreadId, @@ -1563,7 +1610,7 @@ async fn on_command_execution_request_approval_response( } } -/// similar to handle_mcp_tool_call_begin in exec +/// Construct a protocol notification for an MCP tool call begin event. async fn construct_mcp_tool_call_notification( begin_event: McpToolCallBeginEvent, thread_id: String, @@ -1586,7 +1633,7 @@ async fn construct_mcp_tool_call_notification( } } -/// similar to handle_mcp_tool_call_end in exec +/// Construct a protocol notification for an MCP tool call end event. async fn construct_mcp_tool_call_end_notification( end_event: McpToolCallEndEvent, thread_id: String, @@ -1660,10 +1707,12 @@ mod tests { use tokio::sync::Mutex; use tokio::sync::mpsc; + /// Build an empty turn summary store for tests. fn new_turn_summary_store() -> TurnSummaryStore { Arc::new(Mutex::new(HashMap::new())) } + /// Maps session-scoped file-change approval to the expected review decision. #[test] fn file_change_accept_for_session_maps_to_approved_for_session() { let (decision, completion_status) = @@ -1672,6 +1721,7 @@ mod tests { assert_eq!(completion_status, None); } + /// Persists the most recent turn error in the summary store. #[tokio::test] async fn test_handle_error_records_message() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1700,6 +1750,7 @@ mod tests { Ok(()) } + /// Emits a completed turn with no error when no error was recorded. #[tokio::test] async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1732,6 +1783,7 @@ mod tests { Ok(()) } + /// Emits an interrupted turn without attaching the stored error. #[tokio::test] async fn test_handle_turn_interrupted_emits_interrupted_with_error() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1774,6 +1826,7 @@ mod tests { Ok(()) } + /// Emits a failed turn with the stored error payload. #[tokio::test] async fn test_handle_turn_complete_emits_failed_with_error() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1823,6 +1876,7 @@ mod tests { Ok(()) } + /// Sends a v2 turn-plan update notification with translated steps. #[tokio::test] async fn test_handle_turn_plan_update_emits_notification_for_v2() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); @@ -1873,6 +1927,7 @@ mod tests { Ok(()) } + /// Emits token-usage and rate-limit notifications when info is present. #[tokio::test] async fn test_handle_token_count_event_emits_usage_and_rate_limits() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1958,6 +2013,7 @@ mod tests { Ok(()) } + /// Avoids sending notifications when token usage info is missing. #[tokio::test] async fn test_handle_token_count_event_without_usage_info() -> Result<()> { let conversation_id = ThreadId::new(); @@ -1983,6 +2039,7 @@ mod tests { Ok(()) } + /// Builds a begin notification that includes MCP tool-call arguments. #[tokio::test] async fn test_construct_mcp_tool_call_begin_notification_with_args() { let begin_event = McpToolCallBeginEvent { @@ -2021,6 +2078,7 @@ mod tests { assert_eq!(notification, expected); } + /// Handles turn-complete notifications correctly across multiple threads. #[tokio::test] async fn test_handle_turn_complete_emits_error_multiple_turns() -> Result<()> { // Conversation A will have two turns; Conversation B will have one turn. @@ -2141,6 +2199,7 @@ mod tests { Ok(()) } + /// Builds a begin notification with null arguments when absent. #[tokio::test] async fn test_construct_mcp_tool_call_begin_notification_without_args() { let begin_event = McpToolCallBeginEvent { @@ -2179,6 +2238,7 @@ mod tests { assert_eq!(notification, expected); } + /// Builds a successful MCP tool-call completion notification. #[tokio::test] async fn test_construct_mcp_tool_call_end_notification_success() { let content = vec![ContentBlock::TextContent(TextContent { @@ -2233,6 +2293,7 @@ mod tests { assert_eq!(notification, expected); } + /// Builds a failed MCP tool-call completion notification. #[tokio::test] async fn test_construct_mcp_tool_call_end_notification_error() { let end_event = McpToolCallEndEvent { @@ -2275,6 +2336,7 @@ mod tests { assert_eq!(notification, expected); } + /// Emits turn diff notifications for v2 clients. #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); @@ -2311,6 +2373,7 @@ mod tests { Ok(()) } + /// Skips turn diff notifications for v1 clients. #[tokio::test] async fn test_handle_turn_diff_is_noop_for_v1() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 551015f94a..d8e7b192df 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,3 +1,15 @@ +//! JSON-RPC request handling for the Codex app server. +//! +//! This module owns the request/response lifecycle for both the legacy +//! conversation APIs (v1) and the newer thread/turn APIs (v2). It translates +//! incoming `ClientRequest` values into core `CodexThread` operations, manages +//! per-thread side state such as pending interrupts/rollbacks, and emits +//! app-server notifications as core events stream back. +//! +//! The processor also hosts glue for auth flows, configuration refresh, model +//! listing, and other helper endpoints that are not part of the turn stream but +//! still require access to shared state (config, thread manager, login server). + use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; @@ -192,36 +204,51 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +/// Per-thread queue of interrupt requests waiting on `TurnAborted`. type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>; +/// Shared interrupt queues keyed by conversation/thread. pub(crate) type PendingInterrupts = Arc>>; +/// Shared rollback requests keyed by conversation/thread. pub(crate) type PendingRollbacks = Arc>>; /// Per-conversation accumulation of the latest states e.g. error message while a turn runs. #[derive(Default, Clone)] pub(crate) struct TurnSummary { + /// File-change items that have started but not yet completed. pub(crate) file_change_started: HashSet, + /// Latest error observed for the active turn, if any. pub(crate) last_error: Option, } +/// Shared summary store keyed by thread id. pub(crate) type TurnSummaryStore = Arc>>; +/// Default page size for thread list endpoints. const THREAD_LIST_DEFAULT_LIMIT: usize = 25; +/// Maximum page size allowed by thread list endpoints. const THREAD_LIST_MAX_LIMIT: usize = 100; -// Duration before a ChatGPT login attempt is abandoned. +/// Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +/// In-flight ChatGPT login flow state for cancellation/shutdown. struct ActiveLogin { + /// Shutdown handle for the temporary login server. shutdown_handle: ShutdownHandle, + /// Unique identifier for the login flow. login_id: Uuid, } +/// Errors returned when canceling an account login. #[derive(Clone, Copy, Debug)] enum CancelLoginError { + /// No active login exists for the requested id. NotFound(Uuid), } impl Drop for ActiveLogin { + /// Ensure the temporary login server is shut down on drop. fn drop(&mut self) { self.shutdown_handle.shutdown(); } @@ -229,31 +256,47 @@ impl Drop for ActiveLogin { /// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs). pub(crate) struct CodexMessageProcessor { + /// Authentication manager used for login flows and status queries. auth_manager: Arc, + /// Thread manager that owns `CodexThread` instances. thread_manager: Arc, + /// Outgoing request/notification channel to the client. outgoing: Arc, + /// Optional path to the Linux sandbox helper binary. codex_linux_sandbox_exe: Option, + /// Cached config snapshot used for request handling. config: Arc, + /// CLI override values used when reloading config. cli_overrides: Vec<(String, TomlValue)>, + /// Listener shutdown handles keyed by subscription id. conversation_listeners: HashMap>, + /// Mapping of subscription id to the thread it is bound to. listener_thread_ids_by_subscription: HashMap, + /// Currently active login flow, if any. active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. pending_interrupts: PendingInterrupts, // Queue of pending rollback requests per conversation. We reply when ThreadRollback arrives. pending_rollbacks: PendingRollbacks, + /// Accumulated per-thread turn summaries used to emit `TurnCompleted`. turn_summary_store: TurnSummaryStore, + /// Cancellation tokens for in-flight fuzzy file searches. pending_fuzzy_searches: Arc>>>, + /// Feedback logger used for client-side feedback submissions. feedback: CodexFeedback, } +/// API version associated with a request/response flow. #[derive(Clone, Copy, Debug)] pub(crate) enum ApiVersion { + /// Legacy conversation APIs. V1, + /// Thread/turn APIs. V2, } impl CodexMessageProcessor { + /// Load a thread by id string and return the parsed id plus thread handle. async fn load_thread( &self, thread_id: &str, @@ -277,6 +320,7 @@ impl CodexMessageProcessor { Ok((thread_id, thread)) } + /// Construct a new message processor with shared state and config. pub fn new( auth_manager: Arc, thread_manager: Arc, @@ -304,6 +348,7 @@ impl CodexMessageProcessor { } } + /// Reload configuration, returning an error payload on failure. async fn load_latest_config(&self) -> Result { Config::load_with_cli_overrides(self.cli_overrides.clone()) .await @@ -314,9 +359,11 @@ impl CodexMessageProcessor { }) } + /// Convert a review target into a core review request and display hint. fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { + /// Build a standardized invalid-request error payload. fn invalid_request(message: String) -> JSONRPCErrorError { JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -373,6 +420,7 @@ impl CodexMessageProcessor { Ok((review_request, hint)) } + /// Dispatch a client request into the appropriate handler. pub async fn process_request(&mut self, request: ClientRequest) { match request { ClientRequest::Initialize { .. } => { @@ -558,6 +606,7 @@ impl CodexMessageProcessor { } } + /// Dispatch a V2 account login request to the appropriate flow. async fn login_v2(&mut self, request_id: RequestId, params: LoginAccountParams) { match params { LoginAccountParams::ApiKey { api_key } => { @@ -570,6 +619,7 @@ impl CodexMessageProcessor { } } + /// Validate and persist an API key login request. async fn login_api_key_common( &mut self, params: &LoginApiKeyParams, @@ -610,6 +660,7 @@ impl CodexMessageProcessor { } } + /// Handle a legacy (V1) API key login request. async fn login_api_key_v1(&mut self, request_id: RequestId, params: LoginApiKeyParams) { match self.login_api_key_common(¶ms).await { Ok(()) => { @@ -630,6 +681,7 @@ impl CodexMessageProcessor { } } + /// Handle a V2 API key login request and notify account listeners. async fn login_api_key_v2(&mut self, request_id: RequestId, params: LoginApiKeyParams) { match self.login_api_key_common(¶ms).await { Ok(()) => { @@ -660,7 +712,7 @@ impl CodexMessageProcessor { } } - // Build options for a ChatGPT login attempt; performs validation. + /// Build options for a ChatGPT login attempt, performing validation. async fn login_chatgpt_common( &self, ) -> std::result::Result { @@ -685,7 +737,7 @@ impl CodexMessageProcessor { }) } - // Deprecated in favor of login_chatgpt_v2. + /// Start a legacy ChatGPT login flow (deprecated in favor of V2). async fn login_chatgpt_v1(&mut self, request_id: RequestId) { match self.login_chatgpt_common().await { Ok(opts) => match run_login_server(opts) { @@ -776,6 +828,7 @@ impl CodexMessageProcessor { } } + /// Start a V2 ChatGPT login flow and emit account notifications. async fn login_chatgpt_v2(&mut self, request_id: RequestId) { match self.login_chatgpt_common().await { Ok(opts) => match run_login_server(opts) { @@ -869,6 +922,7 @@ impl CodexMessageProcessor { } } + /// Cancel an in-flight ChatGPT login attempt by id. async fn cancel_login_chatgpt_common( &mut self, login_id: Uuid, @@ -884,6 +938,7 @@ impl CodexMessageProcessor { } } + /// Handle a legacy ChatGPT login cancellation request. async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { match self.cancel_login_chatgpt_common(login_id).await { Ok(()) => { @@ -902,6 +957,7 @@ impl CodexMessageProcessor { } } + /// Handle a V2 account login cancellation request. async fn cancel_login_v2(&mut self, request_id: RequestId, params: CancelLoginAccountParams) { let login_id = params.login_id; match Uuid::parse_str(&login_id) { @@ -924,6 +980,7 @@ impl CodexMessageProcessor { } } + /// Perform logout and return the current auth mode (if any). async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { // Cancel any active login attempt. { @@ -945,6 +1002,7 @@ impl CodexMessageProcessor { Ok(self.auth_manager.auth_cached().map(|auth| auth.mode)) } + /// Handle a legacy logout request and emit auth status updates. async fn logout_v1(&mut self, request_id: RequestId) { match self.logout_common().await { Ok(current_auth_method) => { @@ -965,6 +1023,7 @@ impl CodexMessageProcessor { } } + /// Handle a V2 account logout request and emit account updates. async fn logout_v2(&mut self, request_id: RequestId) { match self.logout_common().await { Ok(current_auth_method) => { @@ -985,12 +1044,14 @@ impl CodexMessageProcessor { } } + /// Refresh the auth token when requested, logging failures. async fn refresh_token_if_requested(&self, do_refresh: bool) { if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { tracing::warn!("failed to refresh token whilte getting account: {err}"); } } + /// Respond with the current auth status, optionally including the token. async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); @@ -1040,6 +1101,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Return account details for the current auth mode (if required). async fn get_account(&self, request_id: RequestId, params: GetAccountParams) { let do_refresh = params.refresh_token; @@ -1090,12 +1152,14 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Return the current Codex user agent string. async fn get_user_agent(&self, request_id: RequestId) { let user_agent = get_codex_user_agent(); let response = GetUserAgentResponse { user_agent }; self.outgoing.send_response(request_id, response).await; } + /// Fetch account rate limits for ChatGPT-authenticated users. async fn get_account_rate_limits(&self, request_id: RequestId) { match self.fetch_account_rate_limits().await { Ok(rate_limits) => { @@ -1110,6 +1174,7 @@ impl CodexMessageProcessor { } } + /// Retrieve rate limits from the backend service. async fn fetch_account_rate_limits(&self) -> Result { let Some(auth) = self.auth_manager.auth().await else { return Err(JSONRPCErrorError { @@ -1144,6 +1209,7 @@ impl CodexMessageProcessor { }) } + /// Load and return the user-saved configuration snapshot. async fn get_user_saved_config(&self, request_id: RequestId) { let service = ConfigService::new_with_defaults(self.config.codex_home.clone()); let user_saved_config: UserSavedConfig = match service.load_user_saved_config().await { @@ -1165,6 +1231,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Return best-effort user info derived from cached auth state. async fn get_user_info(&self, request_id: RequestId) { // Read alleged user email from cached auth (best-effort; not verified). let alleged_user_email = self @@ -1176,6 +1243,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Persist the default model selection into user config. async fn set_default_model(&self, request_id: RequestId, params: SetDefaultModelParams) { let SetDefaultModelParams { model, @@ -1203,6 +1271,7 @@ impl CodexMessageProcessor { } } + /// Execute a single command request and stream the result back. async fn exec_one_off_command(&self, request_id: RequestId, params: CommandExecParams) { tracing::debug!("ExecOneOffCommand params: {params:?}"); @@ -1283,6 +1352,7 @@ impl CodexMessageProcessor { }); } + /// Handle legacy new conversation creation and bootstrap a thread. async fn process_new_conversation( &mut self, request_id: RequestId, @@ -1372,6 +1442,7 @@ impl CodexMessageProcessor { } } + /// Start a new thread with the provided configuration and attach a listener. async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { let typesafe_overrides = self.build_thread_config_overrides( params.model, @@ -1484,6 +1555,7 @@ impl CodexMessageProcessor { } } + /// Build config overrides from thread-start parameters. #[allow(clippy::too_many_arguments)] fn build_thread_config_overrides( &self, @@ -1509,6 +1581,7 @@ impl CodexMessageProcessor { } } + /// Archive a thread by id and emit the archive response. async fn thread_archive(&mut self, request_id: RequestId, params: ThreadArchiveParams) { let thread_id = match ThreadId::from_string(¶ms.thread_id) { Ok(id) => id, @@ -1558,6 +1631,7 @@ impl CodexMessageProcessor { } } + /// Request a rollback and hold the response until the rollback event arrives. async fn thread_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) { let ThreadRollbackParams { thread_id, @@ -1603,6 +1677,7 @@ impl CodexMessageProcessor { } } + /// List threads with pagination, sorting, and provider filtering. async fn thread_list(&self, request_id: RequestId, params: ThreadListParams) { let ThreadListParams { cursor, @@ -1635,6 +1710,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Return the list of currently loaded threads with optional pagination. async fn thread_loaded_list(&self, request_id: RequestId, params: ThreadLoadedListParams) { let ThreadLoadedListParams { cursor, limit } = params; let mut data = self @@ -1690,6 +1766,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Subscribe to thread-created events from the thread manager. pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.thread_manager.subscribe_thread_created() } @@ -1715,6 +1792,7 @@ impl CodexMessageProcessor { } } + /// Resume an existing thread, optionally with overrides or explicit history. async fn thread_resume(&mut self, request_id: RequestId, params: ThreadResumeParams) { let ThreadResumeParams { thread_id, @@ -1920,6 +1998,7 @@ impl CodexMessageProcessor { } } + /// Fork an existing thread, optionally applying configuration overrides. async fn thread_fork(&mut self, request_id: RequestId, params: ThreadForkParams) { let ThreadForkParams { thread_id, @@ -2112,6 +2191,7 @@ impl CodexMessageProcessor { .await; } + /// Fetch and return a conversation summary by thread id or rollout path. async fn get_thread_summary( &self, request_id: RequestId, @@ -2170,6 +2250,7 @@ impl CodexMessageProcessor { } } + /// Handle legacy v1 list-conversations requests using the thread listing path. async fn handle_list_conversations( &self, request_id: RequestId, @@ -2203,6 +2284,7 @@ impl CodexMessageProcessor { }; } + /// List threads with cursor pagination and optional model-provider filtering. async fn list_threads_common( &self, requested_page_size: usize, @@ -2307,6 +2389,7 @@ impl CodexMessageProcessor { Ok((items, next_cursor)) } + /// List available models with limit/cursor pagination. async fn list_models( outgoing: Arc, thread_manager: Arc, @@ -2371,6 +2454,7 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + /// Reload MCP server configuration and trigger a refresh in the thread manager. async fn mcp_server_refresh(&self, request_id: RequestId, _params: Option<()>) { let config = match self.load_latest_config().await { Ok(config) => config, @@ -2422,6 +2506,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Kick off an OAuth login flow for a configured MCP server. async fn mcp_server_oauth_login( &self, request_id: RequestId, @@ -2517,6 +2602,7 @@ impl CodexMessageProcessor { } } + /// List MCP server status entries asynchronously to avoid blocking the request loop. async fn list_mcp_server_status( &self, request_id: RequestId, @@ -2536,6 +2622,7 @@ impl CodexMessageProcessor { }); } + /// Collect MCP server status data and send a paginated response. async fn list_mcp_server_status_task( outgoing: Arc, request_id: RequestId, @@ -2619,6 +2706,7 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } + /// Handle legacy resume-conversation requests by mapping them to thread resume. async fn handle_resume_conversation( &self, request_id: RequestId, @@ -2805,6 +2893,7 @@ impl CodexMessageProcessor { } } + /// Handle legacy fork-conversation requests by mapping them to thread forks. async fn handle_fork_conversation( &self, request_id: RequestId, @@ -2965,6 +3054,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Send a JSON-RPC invalid-request error response. async fn send_invalid_request_error(&self, request_id: RequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -2974,6 +3064,7 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } + /// Send a JSON-RPC internal-error response. async fn send_internal_error(&self, request_id: RequestId, message: String) { let error = JSONRPCErrorError { code: INTERNAL_ERROR_CODE, @@ -2983,6 +3074,7 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } + /// Handle legacy archive-conversation requests using thread archiving. async fn archive_conversation( &mut self, request_id: RequestId, @@ -3006,6 +3098,7 @@ impl CodexMessageProcessor { } } + /// Validate and archive a rollout file, shutting down active threads first. async fn archive_thread_common( &mut self, thread_id: ThreadId, @@ -3139,6 +3232,7 @@ impl CodexMessageProcessor { }) } + /// Handle legacy user-message submissions (v1 conversation API). async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) { let SendUserMessageParams { conversation_id, @@ -3183,6 +3277,7 @@ impl CodexMessageProcessor { .await; } + /// Handle v2 user-turn submissions and forward them to the core thread. async fn send_user_turn(&self, request_id: RequestId, params: SendUserTurnParams) { let SendUserTurnParams { conversation_id, @@ -3240,6 +3335,7 @@ impl CodexMessageProcessor { .await; } + /// List skills for one or more working directories. async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) { let SkillsListParams { cwds, force_reload } = params; let cwds = if cwds.is_empty() { @@ -3265,6 +3361,7 @@ impl CodexMessageProcessor { .await; } + /// Persist skill enablement updates to the config. async fn skills_config_write(&self, request_id: RequestId, params: SkillsConfigWriteParams) { let SkillsConfigWriteParams { path, enabled } = params; let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; @@ -3296,6 +3393,8 @@ impl CodexMessageProcessor { } } + /// Interrupt a legacy conversation and reply when the abort event arrives. + /// Interrupt a v1 conversation and defer the response until TurnAborted. async fn interrupt_conversation( &mut self, request_id: RequestId, @@ -3324,6 +3423,8 @@ impl CodexMessageProcessor { let _ = conversation.submit(Op::Interrupt).await; } + /// Start a v2 turn and emit the initial TurnStarted notification. + /// Start a v2 turn by applying overrides, submitting input, and emitting notifications. async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) { let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, @@ -3403,6 +3504,8 @@ impl CodexMessageProcessor { } } + /// Build a synthetic review turn with an optional display hint. + /// Build the synthetic review turn used for review-start responses. fn build_review_turn(turn_id: String, display_text: &str) -> Turn { let items = if display_text.is_empty() { Vec::new() @@ -3425,6 +3528,8 @@ impl CodexMessageProcessor { } } + /// Send the review-start response and the corresponding TurnStarted notification. + /// Send the review-start response and its paired TurnStarted notification. async fn emit_review_started( &self, request_id: &RequestId, @@ -3449,6 +3554,8 @@ impl CodexMessageProcessor { .await; } + /// Start a review turn within the existing thread. + /// Submit a review request against the current thread and emit start events. async fn start_inline_review( &self, request_id: &RequestId, @@ -3479,6 +3586,8 @@ impl CodexMessageProcessor { } } + /// Fork a new review thread and run the review turn there. + /// Fork a dedicated review thread, submit the review, and emit start events. async fn start_detached_review( &mut self, request_id: &RequestId, @@ -3567,6 +3676,8 @@ impl CodexMessageProcessor { Ok(()) } + /// Dispatch a review-start request in either inline or detached mode. + /// Dispatch review-start requests to inline or detached review execution. async fn review_start(&mut self, request_id: RequestId, params: ReviewStartParams) { let ReviewStartParams { thread_id, @@ -3621,6 +3732,8 @@ impl CodexMessageProcessor { } } + /// Interrupt a v2 turn and reply when TurnAborted is observed. + /// Interrupt a v2 turn and respond once the abort event is observed. async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) { let TurnInterruptParams { thread_id, .. } = params; @@ -3644,6 +3757,8 @@ impl CodexMessageProcessor { let _ = thread.submit(Op::Interrupt).await; } + /// Register a legacy conversation listener subscription. + /// Register a v1 event listener and return a subscription id. async fn add_conversation_listener( &mut self, request_id: RequestId, @@ -3667,6 +3782,8 @@ impl CodexMessageProcessor { } } + /// Remove a conversation listener subscription by id. + /// Remove an event subscription and stop its listener task. async fn remove_thread_listener( &mut self, request_id: RequestId, @@ -3697,6 +3814,8 @@ impl CodexMessageProcessor { } } + /// Spawn the per-thread event forwarder and return its subscription id. + /// Spawn the event streaming task for a thread and track its subscription. async fn attach_conversation_listener( &mut self, conversation_id: ThreadId, @@ -3799,6 +3918,8 @@ impl CodexMessageProcessor { Ok(subscription_id) } + /// Compute a git diff against the remote and return it to the client. + /// Compute a git diff against the configured remote for the given cwd. async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) { let diff = git_diff_to_remote(&cwd).await; match diff { @@ -3820,6 +3941,8 @@ impl CodexMessageProcessor { } } + /// Run a fuzzy file search with optional cancellation. + /// Run a fuzzy file search with optional cancellation support. async fn fuzzy_file_search(&mut self, request_id: RequestId, params: FuzzyFileSearchParams) { let FuzzyFileSearchParams { query, @@ -3860,6 +3983,8 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + /// Upload feedback, optionally including rollout logs for a conversation. + /// Upload feedback with optional log attachments. async fn upload_feedback(&self, request_id: RequestId, params: FeedbackUploadParams) { if !self.config.feedback_enabled { let error = JSONRPCErrorError { @@ -3948,6 +4073,8 @@ impl CodexMessageProcessor { } } + /// Resolve the rollout path for a loaded conversation, if available. + /// Resolve a thread id to its rollout path if the thread is loaded. async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option { match self.thread_manager.get_thread(conversation_id).await { Ok(conv) => Some(conv.rollout_path()), @@ -3956,6 +4083,8 @@ impl CodexMessageProcessor { } } +/// Convert core skill metadata into the API response shape. +/// Map core skill metadata into protocol payloads. fn skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_paths: &std::collections::HashSet, @@ -3986,6 +4115,8 @@ fn skills_to_info( .collect() } +/// Convert core skill errors into API error info. +/// Map core skill errors into protocol error entries. fn errors_to_info( errors: &[codex_core::skills::SkillError], ) -> Vec { @@ -4028,6 +4159,7 @@ async fn derive_config_from_params( .await } +/// Read a rollout header and synthesize a conversation summary. pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str, @@ -4096,6 +4228,7 @@ pub(crate) async fn read_summary_from_rollout( }) } +/// Extract event messages from a rollout history file. pub(crate) async fn read_event_msgs_from_rollout( path: &Path, ) -> std::io::Result> { @@ -4114,6 +4247,8 @@ pub(crate) async fn read_event_msgs_from_rollout( .collect()) } +/// Build a conversation summary from rollout head data and session metadata. +/// Build a summary preview from a rollout header, favoring user messages. fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], @@ -4162,6 +4297,8 @@ fn extract_conversation_summary( }) } +/// Map core git metadata into the API wire format. +/// Convert core git metadata into the summary representation. fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { sha: git_info.commit_hash.clone(), @@ -4170,6 +4307,8 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { } } +/// Parse an RFC3339 timestamp into UTC. +/// Parse an RFC3339 timestamp into a UTC datetime. fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) @@ -4178,6 +4317,8 @@ fn parse_datetime(timestamp: Option<&str>) -> Option> { }) } +/// Fetch the file mtime as an RFC3339 timestamp, falling back to the created time. +/// Use file mtime to derive an updated_at timestamp, falling back to created_at. async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { let updated_at = tokio::fs::metadata(path) .await @@ -4190,6 +4331,7 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option Thread { let ConversationSummary { conversation_id, @@ -4236,6 +4378,7 @@ mod tests { use serde_json::json; use tempfile::TempDir; + /// Prefers the first plain user message when building a preview. #[test] fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; @@ -4299,6 +4442,7 @@ mod tests { Ok(()) } + /// Returns an empty preview when a rollout has no user messages. #[tokio::test] async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { use codex_protocol::protocol::RolloutItem; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 337be81e5d..1f823c273d 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -91,13 +91,13 @@ enum Subcommand { /// Remove stored authentication credentials. Logout(LogoutCommand), - /// [experimental] Run Codex as an MCP server and manage MCP servers. + /// `experimental` Run Codex as an MCP server and manage MCP servers. Mcp(McpCli), - /// [experimental] Run the Codex MCP server (stdio transport). + /// `experimental` Run the Codex MCP server (stdio transport). McpServer, - /// [experimental] Run the app server or related tooling. + /// `experimental` Run the app server or related tooling. AppServer(AppServerCommand), /// Generate shell completion scripts. @@ -282,17 +282,17 @@ struct AppServerCommand { /// enabled = false /// ``` /// - /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. + /// See for more details. #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, } #[derive(Debug, clap::Subcommand)] enum AppServerSubcommand { - /// [experimental] Generate TypeScript bindings for the app server protocol. + /// `experimental` Generate TypeScript bindings for the app server protocol. GenerateTs(GenerateTsCommand), - /// [experimental] Generate JSON Schema for the app server protocol. + /// `experimental` Generate JSON Schema for the app server protocol. GenerateJsonSchema(GenerateJsonSchemaCommand), } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e954d88c1a..d33573dd41 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -207,7 +207,7 @@ "minimum": 0.0 }, "mcp_oauth_credentials_store": { - "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file.", + "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file.", "default": null, "allOf": [ { @@ -268,7 +268,7 @@ ] }, "notice": { - "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details", + "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notice`] for more details", "allOf": [ { "$ref": "#/definitions/Notice" @@ -423,7 +423,7 @@ "type": "string" }, "AltScreenMode": { - "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see ) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", "oneOf": [ { "description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.", @@ -627,7 +627,7 @@ "type": "string" }, "model_provider": { - "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", + "description": "The key in the `model_providers` map identifying the [`crate::model_provider_info::ModelProviderInfo`] to use.", "type": "string" }, "model_reasoning_effort": { @@ -1145,7 +1145,7 @@ "additionalProperties": false }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "description": "See ", "type": "string", "enum": [ "none", @@ -1157,7 +1157,7 @@ ] }, "ReasoningSummary": { - "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See ", "oneOf": [ { "type": "string", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a9dc1004ab..1da1371d5e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -184,7 +184,7 @@ pub struct Codex { pub(crate) agent_status: watch::Receiver, } -/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], +/// Wrapper returned by `Codex::spawn` containing the spawned [`Codex`], /// the submission id for the initial `ConfigureSession` request and the /// unique session id. pub struct CodexSpawnOk { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 956df12e9c..547930a5e5 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -270,7 +270,7 @@ pub struct Config { /// Preferred store for MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. - /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// /// file: CODEX_HOME/.credentials.json /// This file will be readable to Codex and other applications running as the same user. /// auto (default): keyring if available, otherwise file. @@ -844,7 +844,7 @@ pub struct ConfigToml { /// Preferred backend for storing MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. - /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// /// file: Use a file in the Codex home directory. /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. #[serde(default)] @@ -955,7 +955,7 @@ pub struct ConfigToml { pub windows_wsl_setup_acknowledged: Option, /// Collection of in-product notices (different from notifications) - /// See [`crate::config::types::Notices`] for more details + /// See [`crate::config::types::Notice`] for more details pub notice: Option, /// Legacy, now use features diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8630f15d1..2fc204e683 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -17,7 +17,7 @@ use codex_protocol::openai_models::ReasoningEffort; pub struct ConfigProfile { pub model: Option, /// The key in the `model_providers` map identifying the - /// [`ModelProviderInfo`] to use. + /// [`crate::model_provider_info::ModelProviderInfo`] to use. pub model_provider: Option, pub approval_policy: Option, pub sandbox_mode: Option, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 46c06d9a22..7798d661b7 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -197,7 +197,7 @@ const fn default_enabled() -> bool { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + /// Stdio { command: String, #[serde(default)] @@ -209,7 +209,7 @@ pub enum McpServerTransportConfig { #[serde(default, skip_serializing_if = "Option::is_none")] cwd: Option, }, - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + /// StreamableHttp { url: String, /// Name of the environment variable to read for an HTTP bearer token. diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9a8791eeef..b315625d13 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -71,7 +71,7 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// /// (*) Only available on macOS via managed device profiles. /// -/// See https://developers.openai.com/codex/security for details. +/// See for details. /// /// When loading the config stack for a thread, there should be a `cwd` /// associated with it such that `cwd` should be `Some(...)`. Only for diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 4ded10a3d9..c513e01be6 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -14,7 +14,7 @@ use std::sync::RwLock; /// However, future users of this should use this with caution as a result. /// In addition, we want to be confident that this value is used for ALL clients and doing that requires a /// lot of wiring and it's easy to miss code paths by doing so. -/// See https://github.com/openai/codex/pull/3388/files for an example of what that would look like. +/// See for an example of what that would look like. /// Finally, we want to make sure this is set for ALL mcp clients without needing to know a special env var /// or having to set data that they already specified in the mcp initialize request somewhere else. /// diff --git a/codex-rs/core/src/review_format.rs b/codex-rs/core/src/review_format.rs index 10f53066a7..d2931adfc5 100644 --- a/codex-rs/core/src/review_format.rs +++ b/codex-rs/core/src/review_format.rs @@ -16,7 +16,7 @@ const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; /// Format a full review findings block as plain text lines. /// /// - When `selection` is `Some`, each item line includes a checkbox marker: -/// "[x]" for selected items and "[ ]" for unselected. Missing indices +/// `"[x]"` for selected items and `"[ ]"` for unselected. Missing indices /// default to selected. /// - When `selection` is `None`, the marker is omitted and a simple bullet is /// rendered ("- Title — path:start-end"). diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3e53882fcf..07515767da 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -436,7 +436,7 @@ async fn traverse_directories_for_paths_updated( }) } -/// Pagination cursor token format: "|" where `ts` uses +/// Pagination cursor token format: `"|"` where `ts` uses /// YYYY-MM-DDThh-mm-ss (UTC, second precision). /// The cursor orders files by the requested sort key (timestamp desc, then UUID desc). pub fn parse_cursor(token: &str) -> Option { diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 13eace9f3c..1dccde0fc4 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -38,7 +38,7 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; -/// Records all [`ResponseItem`]s for a session and flushes them to disk after +/// Records all [`codex_protocol::models::ResponseItem`]s for a session and flushes them to disk after /// every update. /// /// Rollouts are recorded as JSONL and can be inspected with tools such as: diff --git a/codex-rs/execpolicy/src/amend.rs b/codex-rs/execpolicy/src/amend.rs index 9114d3a64f..b032eef571 100644 --- a/codex-rs/execpolicy/src/amend.rs +++ b/codex-rs/execpolicy/src/amend.rs @@ -55,7 +55,7 @@ pub enum AmendError { } /// Note this thread uses advisory file locking and performs blocking I/O, so it should be used with -/// [`tokio::task::spawn_blocking`] when called from an async context. +/// `tokio::task::spawn_blocking` when called from an async context. pub fn blocking_append_allow_prefix_rule( policy_path: &Path, prefix: &[String], diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index d55eb929f3..aacbd19c15 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -1,3 +1,13 @@ +//! Fuzzy file search used by the CLI and TUI. +//! +//! This crate walks a directory tree, scores file paths with `nucleo_matcher`, +//! and returns the highest-ranked matches. The search runs in parallel using +//! `ignore::WalkParallel`, with each worker maintaining its own local heap of +//! best matches; results are merged after traversal. +//! +//! Cancellation is cooperative: workers periodically check a shared flag so +//! callers can stop long searches without tearing down threads. + use ignore::WalkBuilder; use ignore::overrides::OverrideBuilder; use nucleo_matcher::Matcher; @@ -50,16 +60,29 @@ pub fn file_name_from_path(path: &str) -> String { #[derive(Debug)] pub struct FileSearchResults { + /// Top matches, already sorted by score and name for stable output. pub matches: Vec, + /// Total number of files that matched the query before truncation. pub total_match_count: usize, } +/// Reporting interface used by the CLI wrapper. +/// +/// `run_main` emits matches as it streams them and uses the warning hooks to +/// explain truncation or missing search patterns. pub trait Reporter { + /// Emit a single matched file. fn report_match(&self, file_match: &FileMatch); + /// Warn that some matches were omitted due to the limit. fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize); + /// Warn that no search pattern was provided. fn warn_no_search_pattern(&self, search_directory: &Path); } +/// CLI entry point that resolves arguments, runs the search, and reports output. +/// +/// When no pattern is provided, this prints a directory listing to stderr/stdout +/// (platform-specific) and returns successfully. pub async fn run_main( Cli { pattern, @@ -129,8 +152,11 @@ pub async fn run_main( Ok(()) } -/// The worker threads will periodically check `cancel_flag` to see if they -/// should stop processing files. +/// Run a fuzzy search over files rooted at `search_directory`. +/// +/// The worker threads periodically check `cancel_flag` to see if they should +/// stop processing files. `respect_gitignore` controls whether git ignore rules +/// are applied by the walker. #[allow(clippy::too_many_arguments)] pub fn run( pattern_text: &str, diff --git a/codex-rs/file-search/src/main.rs b/codex-rs/file-search/src/main.rs index ef39174df5..4a0f330ee3 100644 --- a/codex-rs/file-search/src/main.rs +++ b/codex-rs/file-search/src/main.rs @@ -1,3 +1,8 @@ +//! CLI entrypoint for the `codex-file-search` binary. +//! +//! This module wires the parsed CLI options to the search runner and implements a +//! `Reporter` that prints either JSON or human-readable output to stdout/stderr. + use std::io::IsTerminal; use std::path::Path; @@ -8,6 +13,7 @@ use codex_file_search::Reporter; use codex_file_search::run_main; use serde_json::json; +/// Parse CLI arguments, run the search, and stream matches to stdout. #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -19,12 +25,16 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// Reporter that formats matches for stdout/stderr output. struct StdioReporter { + /// Emit each match as a JSON payload instead of plain text. write_output_as_json: bool, + /// Highlight matched character indices when stdout is a terminal. show_indices: bool, } impl Reporter for StdioReporter { + /// Print a single match in either JSON or human-readable form. fn report_match(&self, file_match: &FileMatch) { if self.write_output_as_json { println!("{}", serde_json::to_string(&file_match).unwrap()); @@ -33,6 +43,7 @@ impl Reporter for StdioReporter { .indices .as_ref() .expect("--compute-indices was specified"); + // `indices` is guaranteed to be sorted in ascending order. Instead // of calling `contains` for every character (which would be O(N^2) // in the worst-case), walk through the `indices` vector once while @@ -58,6 +69,7 @@ impl Reporter for StdioReporter { } } + /// Warn when the reported matches are truncated due to the limit. fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize) { if self.write_output_as_json { let value = json!({"matches_truncated": true}); @@ -69,6 +81,7 @@ impl Reporter for StdioReporter { } } + /// Warn when no search pattern was provided and a directory listing is used. fn warn_no_search_pattern(&self, search_directory: &Path) { eprintln!( "No search pattern specified. Showing the contents of the current directory ({}):", diff --git a/codex-rs/keyring-store/src/lib.rs b/codex-rs/keyring-store/src/lib.rs index 10dad3a98a..13103c1dd2 100644 --- a/codex-rs/keyring-store/src/lib.rs +++ b/codex-rs/keyring-store/src/lib.rs @@ -1,3 +1,9 @@ +//! Keyring-backed credential storage. +//! +//! This crate wraps the `keyring` crate behind a small trait so callers can +//! swap in mock implementations for tests while sharing the same error +//! handling and logging behavior. + use keyring::Entry; use keyring::Error as KeyringError; use std::error::Error; @@ -5,22 +11,27 @@ use std::fmt; use std::fmt::Debug; use tracing::trace; +/// Error wrapper for keyring-backed credential operations. #[derive(Debug)] pub enum CredentialStoreError { + /// An error returned by the underlying keyring implementation. Other(KeyringError), } impl CredentialStoreError { + /// Wrap a keyring error in the credential-store error type. pub fn new(error: KeyringError) -> Self { Self::Other(error) } + /// Return a user-facing message for this error. pub fn message(&self) -> String { match self { Self::Other(error) => error.to_string(), } } + /// Return the underlying keyring error. pub fn into_error(self) -> KeyringError { match self { Self::Other(error) => error, @@ -40,15 +51,20 @@ impl Error for CredentialStoreError {} /// Shared credential store abstraction for keyring-backed implementations. pub trait KeyringStore: Debug + Send + Sync { + /// Load a stored credential value for the given service and account. fn load(&self, service: &str, account: &str) -> Result, CredentialStoreError>; + /// Persist a credential value for the given service and account. fn save(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError>; + /// Delete a stored credential, returning whether it was present. fn delete(&self, service: &str, account: &str) -> Result; } +/// Default keyring-backed credential store. #[derive(Debug)] pub struct DefaultKeyringStore; impl KeyringStore for DefaultKeyringStore { + /// Load the credential via the OS keyring. fn load(&self, service: &str, account: &str) -> Result, CredentialStoreError> { trace!("keyring.load start, service={service}, account={account}"); let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?; @@ -68,6 +84,7 @@ impl KeyringStore for DefaultKeyringStore { } } + /// Save the credential via the OS keyring. fn save(&self, service: &str, account: &str, value: &str) -> Result<(), CredentialStoreError> { trace!( "keyring.save start, service={service}, account={account}, value_len={}", @@ -86,6 +103,7 @@ impl KeyringStore for DefaultKeyringStore { } } + /// Delete the credential via the OS keyring. fn delete(&self, service: &str, account: &str) -> Result { trace!("keyring.delete start, service={service}, account={account}"); let entry = Entry::new(service, account).map_err(CredentialStoreError::new)?; @@ -106,6 +124,7 @@ impl KeyringStore for DefaultKeyringStore { } } +/// Test-only helpers for keyring-backed interactions. pub mod tests { use super::CredentialStoreError; use super::KeyringStore; @@ -117,12 +136,15 @@ pub mod tests { use std::sync::Mutex; use std::sync::PoisonError; + /// In-memory keyring store for unit and integration tests. #[derive(Default, Clone, Debug)] pub struct MockKeyringStore { + /// Per-account mock credentials keyed by account name. credentials: Arc>>>, } impl MockKeyringStore { + /// Fetch or create the mock credential entry for an account. pub fn credential(&self, account: &str) -> Arc { let mut guard = self .credentials @@ -134,6 +156,7 @@ pub mod tests { .clone() } + /// Read the stored password value for an account, if any. pub fn saved_value(&self, account: &str) -> Option { let credential = { let guard = self @@ -145,11 +168,13 @@ pub mod tests { credential.get_password().ok() } + /// Force a keyring error for an account's credential. pub fn set_error(&self, account: &str, error: KeyringError) { let credential = self.credential(account); credential.set_error(error); } + /// Whether the mock store currently has an entry for the account. pub fn contains(&self, account: &str) -> bool { let guard = self .credentials @@ -160,6 +185,7 @@ pub mod tests { } impl KeyringStore for MockKeyringStore { + /// Load the credential from the in-memory mock store. fn load( &self, _service: &str, @@ -184,6 +210,7 @@ pub mod tests { } } + /// Save the credential into the in-memory mock store. fn save( &self, _service: &str, @@ -196,6 +223,7 @@ pub mod tests { .map_err(CredentialStoreError::new) } + /// Delete the credential from the in-memory mock store. fn delete(&self, _service: &str, account: &str) -> Result { let credential = { let guard = self diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs index 7418bea854..cae0e05124 100644 --- a/codex-rs/mcp-types/src/lib.rs +++ b/codex-rs/mcp-types/src/lib.rs @@ -817,7 +817,7 @@ impl From for serde_json::Value { /// The severity of a log message. /// /// These map to syslog message severities, as specified in RFC-5424: -/// https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 +/// #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub enum LoggingLevel { #[serde(rename = "alert")] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 0febd2fdd5..0d1c1d5dfe 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -8,7 +8,7 @@ use crate::openai_models::ReasoningEffort; /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries +/// See #[derive( Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, )] @@ -103,7 +103,7 @@ pub enum TrustLevel { /// that alternate screen buffers should not have scrollback. /// /// **Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode -/// (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This +/// (see ) to comply with the xterm spec. This /// is by design and not configurable in Zellij—there is no option to enable scrollback in /// alternate screen mode. /// diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 6088fe277c..ee538d9e02 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -11,7 +11,7 @@ use ts_rs::TS; use crate::config_types::Verbosity; -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning +/// See #[derive( Debug, Serialize, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 29eabe2bfa..7e538d4f7b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -89,7 +89,7 @@ pub enum Op { }, /// Similar to [`Op::UserInput`], but contains additional context required - /// for a turn of a [`crate::codex_thread::CodexThread`]. + /// for a turn of a `CodexThread`. UserTurn { /// User input items, see `InputItem` items: Vec, diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index bbe0484d4a..799811cd1f 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -38,7 +38,7 @@ pub struct Args { #[arg(long)] pub port: Option, - /// Path to a JSON file to write startup info (single line). Includes {"port": }. + /// Path to a JSON file to write startup info (single line). Includes `{"port": }`. #[arg(long, value_name = "FILE")] pub server_info: Option, diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index a3a256374b..0c50547e84 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -1,6 +1,6 @@ //! This file handles all logic related to managing MCP OAuth credentials. //! All credentials are stored using the keyring crate which uses os-specific keyring services. -//! https://crates.io/crates/keyring +//! //! macOS: macOS keychain. //! Windows: Windows Credential Manager //! Linux: DBus-based Secret Service, the kernel keyutils, and a combo of the two diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index b977389eab..b02c28b419 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -96,7 +96,7 @@ pub type SendElicitation = Box< >; /// MCP client implemented on top of the official `rmcp` SDK. -/// https://github.com/modelcontextprotocol/rust-sdk +/// pub struct RmcpClient { state: Mutex, } @@ -213,7 +213,7 @@ impl RmcpClient { } /// Perform the initialization handshake with the MCP server. - /// https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization + /// pub async fn initialize( &self, params: InitializeRequestParams, diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index 54746c1705..91a0a95e21 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -1,3 +1,9 @@ +//! Warning helpers for additional writable directories. +//! +//! The TUI needs to explain why `--add-dir` roots might be ignored under a +//! read-only sandbox. This module formats those warnings without owning any +//! UI presentation logic. + use codex_core::protocol::SandboxPolicy; use std::path::PathBuf; @@ -20,6 +26,7 @@ pub fn add_dir_warning_message( } } +/// Format the read-only warning for the given additional directories. fn format_warning(additional_dirs: &[PathBuf]) -> String { let joined_paths = additional_dirs .iter() diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b256ccd200..7f281a673a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,3 +1,15 @@ +//! Application state and event loop for the TUI. +//! +//! This module owns the [`App`] state machine that bridges Codex threads, UI +//! widgets, and user input. The `App` is the single mutable owner of UI state; +//! background tasks communicate via [`AppEventSender`] and never mutate state +//! directly. The main loop processes [`AppEvent`] values emitted by async tasks +//! alongside [`TuiEvent`] input from the terminal. +//! +//! `App` is also responsible for high-level session lifecycle transitions such as +//! start/resume/fork flows, overlay activation, and clean shutdown. It orchestrates +//! the chat widget, transcript overlays, and external editor integration while +//! keeping all state mutations serialized on the main loop. use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event::ExitMode; @@ -74,28 +86,46 @@ use tokio::select; use tokio::sync::broadcast; use tokio::sync::mpsc::unbounded_channel; +/// Footer hint shown while an external editor session is active. const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; +/// Summary of a completed TUI session. #[derive(Debug, Clone)] pub struct AppExitInfo { + /// Aggregate token usage captured during the session. pub token_usage: TokenUsage, + + /// Thread to resume, if the session created one. pub thread_id: Option, + + /// Any update action the user confirmed before exit. pub update_action: Option, + + /// Why the session ended. pub exit_reason: ExitReason, } +/// Internal control flow for the app event loop. #[derive(Debug)] pub(crate) enum AppRunControl { Continue, Exit(ExitReason), } +/// High-level reason the UI shut down. #[derive(Debug, Clone)] pub enum ExitReason { + /// The user explicitly requested exit (quit, cancel, or shutdown). UserRequested, + + /// A fatal error occurred and the UI cannot continue. Fatal(String), } +/// Build a short session summary when any token usage has accrued. +/// +/// The summary includes a human-readable usage line and, when available, a resume command for the +/// thread created during the session. fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Option { if token_usage.is_zero() { return None; @@ -109,6 +139,7 @@ fn session_summary(token_usage: TokenUsage, thread_id: Option) -> Opti }) } +/// Extract skill loading errors scoped to a specific working directory. fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { response .skills @@ -118,6 +149,10 @@ fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec) { let Some(DeprecationNoticeEvent { summary, details }) = notice else { return; @@ -148,12 +184,20 @@ fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option, } +/// Decide whether to prompt for a model migration based on history and availability. +/// +/// This suppresses prompts when the target model matches the current selection or when the +/// migration has already been acknowledged for the same source/target pair. fn should_show_model_migration_prompt( current_model: &str, target_model: &str, @@ -187,6 +231,7 @@ fn should_show_model_migration_prompt( false } +/// Return true if the migration prompt is hidden by config for a given key. fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { match migration_config_key { HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config @@ -200,6 +245,7 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool } } +/// Find the model preset that matches the target upgrade model slug. fn target_preset_for_upgrade<'a>( available_models: &'a [ModelPreset], target_model: &str, @@ -209,6 +255,10 @@ fn target_preset_for_upgrade<'a>( .find(|preset| preset.model == target_model) } +/// Show a model migration prompt if required and return early exit info when requested. +/// +/// This resolves upgrade metadata for the current model, bails out if a prompt is hidden, then +/// drives the prompt UI and applies any confirmed config changes before continuing. async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, @@ -314,38 +364,77 @@ async fn handle_model_migration_prompt_if_needed( None } +/// Central, mutable TUI state owned by the main event loop. +/// +/// `App` owns the high-level session lifecycle, coordinates chat widget swaps +/// (new, resume, fork), and mediates between async events and rendering so UI +/// updates remain serialized and consistent. pub(crate) struct App { + /// Thread manager used to create, resume, and shut down Codex sessions. pub(crate) server: Arc, + + /// Sender for app-local events emitted by async tasks and helpers. pub(crate) app_event_tx: AppEventSender, + + /// Primary chat widget, including the current thread and input buffer. + /// + /// This is swapped when starting a new session or resuming/forking another thread. pub(crate) chat_widget: ChatWidget, + + /// Shared auth manager used by onboarding and session startup. pub(crate) auth_manager: Arc, + /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, + + /// Currently selected model identifier as shown in the UI. pub(crate) current_model: String, + + /// Active config profile name, if one is selected. pub(crate) active_profile: Option, + /// Background file search coordinator for the command palette. pub(crate) file_search: FileSearchManager, + /// Ordered transcript cells rendered in the main scrollback view. pub(crate) transcript_cells: Vec>, - // Pager overlay state (Transcript or Static like Diff) + /// Pager overlay state (transcript view or static diff, etc.). + /// + /// When set, the overlay takes ownership of rendering and scrollback output. pub(crate) overlay: Option, + + /// Buffered scrollback lines to flush when exiting overlays. + /// + /// These lines are captured while an overlay is active so the transcript can catch up when + /// the overlay is dismissed. pub(crate) deferred_history_lines: Vec>, + + /// Tracks whether scrollback emission has begun this session. has_emitted_history_lines: bool, + /// Whether the terminal supports keyboard enhancement flags. pub(crate) enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. + /// + /// This flag is shared with the animation loop so it can stop cleanly when the app exits. pub(crate) commit_anim_running: Arc, - // Esc-backtracking state grouped + /// Esc-backtracking state grouped together for rollback handling. + /// + /// This tracks whether a rollback is pending and which history cells must be trimmed. pub(crate) backtrack: crate::app_backtrack::BacktrackState, + /// When set, the next draw re-renders the transcript into terminal scrollback once. /// /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed /// transcript cells. pub(crate) backtrack_render_pending: bool, + + /// Feedback submission state for the session. pub(crate) feedback: codex_feedback::CodexFeedback, + /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -353,18 +442,24 @@ pub(crate) struct App { /// stopping a thread (e.g., before starting a new one). suppress_shutdown_complete: bool, - // One-shot suppression of the next world-writable scan after user confirmation. + /// One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, - // TODO(jif) drop once new UX is here. - // Track external agent approvals spawned via AgentControl. + /// TODO(jif): drop once new UX is here; tracks external agent approvals spawned via AgentControl. /// Map routed approval IDs to their originating external threads and original IDs. external_approval_routes: HashMap, + /// Buffered Codex events while external approvals are pending. + /// + /// These events are replayed once the external approval flow completes to preserve ordering. paused_codex_events: VecDeque, } impl App { + /// Request shutdown for the active thread and clear any rollback guard. + /// + /// This clears pending rollback state, sends a shutdown op, and removes the thread from the + /// thread manager once it exits. async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -375,6 +470,11 @@ impl App { } } + /// Run the main TUI event loop until the user exits or a fatal error occurs. + /// + /// The loop wires up the initial session, spawns background tasks (commit animation, file + /// search, model refresh), and then interleaves terminal input, async app events, and redraw + /// ticks to keep the UI responsive while preserving a single mutation owner. #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -579,6 +679,7 @@ impl App { Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } + // Listen on new thread creation due to collab tools. created = thread_created_rx.recv(), if listen_for_threads => { match created { @@ -609,6 +710,10 @@ impl App { }) } + /// Handle a terminal event, routing it to overlays or the chat widget. + /// + /// Overlay events are handled first; otherwise this translates terminal input into chat widget + /// updates and draw requests, including paste normalization and cursor placement. pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, @@ -661,6 +766,10 @@ impl App { Ok(AppRunControl::Continue) } + /// Handle a single app event and update UI state accordingly. + /// + /// This preloads model metadata, routes the event to the right state transition, updates + /// overlays/history, and returns the control flow outcome for the main loop. async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { let model_info = self .server @@ -943,12 +1052,14 @@ impl App { .submit_op(Op::PatchApproval { id, decision }); } } + // Standard path where this is not an external approval response. _ => self.chat_widget.submit_op(op), }, AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); + // Enter alternate screen using TUI helper and build pager lines let _ = tui.enter_alt_screen(); let pager_lines: Vec> = if text.trim().is_empty() { @@ -1421,6 +1532,10 @@ impl App { Ok(AppRunControl::Continue) } + /// Apply app-level side effects for a Codex event before handing it to the chat widget. + /// + /// This clears any shutdown suppression, emits skill load warnings, updates backtrack state, + /// then forwards the event into `ChatWidget`. fn handle_codex_event_now(&mut self, event: Event) { if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) { self.suppress_shutdown_complete = false; @@ -1455,6 +1570,7 @@ impl App { self.chat_widget.handle_codex_event(event); } + /// Forward an approval or control op to an external thread. async fn forward_external_op(&self, thread_id: ThreadId, op: Op) { let thread = match self.server.get_thread(thread_id).await { Ok(thread) => thread, @@ -1468,6 +1584,7 @@ impl App { } } + /// Resume any buffered Codex events once all external approvals are resolved. fn finish_external_approval(&mut self) { if self.external_approval_routes.is_empty() { while let Some(event) = self.paused_codex_events.pop_front() { @@ -1476,6 +1593,7 @@ impl App { } } + /// Attach to a newly created thread and wire up its event stream. async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { let thread = match self.server.get_thread(thread_id).await { Ok(thread) => thread, @@ -1505,6 +1623,7 @@ impl App { Ok(()) } + /// Map reasoning effort configuration to a short label for UI display. fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -1516,6 +1635,7 @@ impl App { } } + /// Return the reasoning label unless the model suppresses it (e.g., codex-auto). fn reasoning_label_for( model: &str, reasoning_effort: Option, @@ -1523,15 +1643,21 @@ impl App { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } + /// Return the current session token usage snapshot. pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } + /// Apply a reasoning-effort update to the widget and config. fn on_update_reasoning_effort(&mut self, effort: Option) { self.chat_widget.set_reasoning_effort(effort); self.config.model_reasoning_effort = effort; } + /// Launch the external editor and apply its results to the composer. + /// + /// This resolves the editor command, runs it with restored terminal state, then applies the + /// cleaned text or emits an error before returning to the normal UI. async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { let editor_cmd = match external_editor::resolve_editor_command() { Ok(cmd) => cmd, @@ -1577,6 +1703,7 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Mark the external editor as requested and update the footer hint. fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) { self.chat_widget .set_external_editor_state(ExternalEditorState::Requested); @@ -1587,6 +1714,7 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Reset external editor state and clear the footer hint. fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) { self.chat_widget .set_external_editor_state(ExternalEditorState::Closed); @@ -1594,6 +1722,10 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Handle global keybindings that are owned by the app (overlays, backtrack). + /// + /// This routes overlay commands, external-editor shortcuts, and backtrack flows before + /// delegating remaining input to the chat widget. async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1622,6 +1754,7 @@ impl App { self.request_external_editor_launch(tui); } } + // Esc primes/advances backtracking only in normal (not working) mode // with the composer focused and empty. In any other state, forward // Esc so the active UI (e.g. status indicator, modals, popups) @@ -1639,6 +1772,7 @@ impl App { self.chat_widget.handle_key_event(key_event); } } + // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, @@ -1665,11 +1799,13 @@ impl App { self.chat_widget.handle_key_event(key_event); } _ => { + // Ignore Release key events. } }; } + /// Run the world-writable scan on a blocking thread and emit warnings on failure. #[cfg(target_os = "windows")] fn spawn_world_writable_scan( cwd: PathBuf, @@ -1699,6 +1835,7 @@ impl App { } } +/// Test helpers and behavior checks for the app event loop. #[cfg(test)] mod tests { use super::*; @@ -1728,6 +1865,7 @@ mod tests { use std::sync::atomic::AtomicBool; use tempfile::tempdir; + /// Build a minimal `App` instance for unit tests. async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); @@ -1766,6 +1904,7 @@ mod tests { } } + /// Build a test `App` along with its event and op channels. async fn make_test_app_with_channels() -> ( App, tokio::sync::mpsc::UnboundedReceiver, @@ -1812,10 +1951,12 @@ mod tests { ) } + /// Return a fresh copy of all model presets for tests. fn all_model_presets() -> Vec { codex_core::models_manager::model_presets::all_model_presets().clone() } + /// Flatten model migration copy into plain text for assertions. fn model_migration_copy_to_plain_text( copy: &crate::model_migration::ModelMigrationCopy, ) -> String { @@ -1837,6 +1978,7 @@ mod tests { s } + /// Shows migration prompts only when a model is deprecated with an upgrade target. #[tokio::test] async fn model_migration_prompt_only_shows_for_deprecated_models() { let seen = BTreeMap::new(); @@ -1872,6 +2014,7 @@ mod tests { )); } + /// Honors migration hide flags and no-op self-target upgrades. #[tokio::test] async fn model_migration_prompt_respects_hide_flag_and_self_target() { let mut seen = BTreeMap::new(); @@ -1890,6 +2033,7 @@ mod tests { )); } + /// Skips migration prompts when the target model preset is missing. #[tokio::test] async fn model_migration_prompt_skips_when_target_missing() { let mut available = all_model_presets(); @@ -1919,6 +2063,7 @@ mod tests { assert!(target_preset_for_upgrade(&available, "missing-target").is_none()); } + /// Prompts when the current model is hidden but has a valid upgrade path. #[tokio::test] async fn model_migration_prompt_shows_for_hidden_model() { let codex_home = tempdir().expect("temp codex home"); @@ -1973,6 +2118,7 @@ mod tests { ); } + /// Updates both app config and chat widget when reasoning effort changes. #[tokio::test] async fn update_reasoning_effort_updates_config() { let mut app = make_test_app().await; @@ -1992,6 +2138,7 @@ mod tests { ); } + /// Deduplicates backtrack selections when history cells share a turn. #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; @@ -2089,6 +2236,7 @@ mod tests { assert_eq!(rollback_turns, Some(1)); } + /// Sends shutdown when starting a new session after another thread is active. #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; @@ -2126,11 +2274,13 @@ mod tests { } } + /// Skips building a summary when token usage is zero. #[tokio::test] async fn session_summary_skip_zero_usage() { assert!(session_summary(TokenUsage::default(), None).is_none()); } + /// Includes a resume hint when a thread id is available. #[tokio::test] async fn session_summary_includes_resume_hint() { let usage = TokenUsage { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 73d238f9a4..53b9921ac5 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -86,7 +86,10 @@ pub(crate) struct BacktrackSelection { /// the same active thread we issued the request for. #[derive(Debug, Clone)] pub(crate) struct PendingBacktrackRollback { + /// Selection that triggered the rollback request. pub(crate) selection: BacktrackSelection, + + /// Thread id the rollback was issued against. pub(crate) thread_id: Option, } @@ -381,6 +384,7 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } + /// Apply a confirmed backtrack selection and schedule a redraw. pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, @@ -390,6 +394,7 @@ impl App { tui.frame_requester().schedule_frame(); } + /// React to rollback-related core events and clear guards when needed. pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { match event { EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), @@ -420,6 +425,7 @@ impl App { self.backtrack_render_pending = true; } + /// Build a backtrack selection if the base thread still matches the active thread. fn backtrack_selection(&self, nth_user_message: usize) -> Option { let base_id = self.backtrack.base_id?; if self.chat_widget.thread_id() != Some(base_id) { @@ -444,6 +450,7 @@ impl App { } } +/// Trim the transcript cells to everything before the requested user message index. fn trim_transcript_cells_to_nth_user( transcript_cells: &mut Vec>, nth_user_message: usize, @@ -457,10 +464,12 @@ fn trim_transcript_cells_to_nth_user( } } +/// Count user messages since the most recent session start marker. pub(crate) fn user_count(cells: &[Arc]) -> usize { user_positions_iter(cells).count() } +/// Return the transcript index of the nth user message, if present. fn nth_user_position( cells: &[Arc], nth: usize, @@ -470,6 +479,7 @@ fn nth_user_position( .find_map(|(i, idx)| (i == nth).then_some(idx)) } +/// Iterate transcript indices for user messages after the last session start cell. fn user_positions_iter( cells: &[Arc], ) -> impl Iterator + '_ { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f34e1d7d5a..ea5d7bf2ef 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -25,25 +25,37 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; +/// Windows sandbox enablement mode chosen by the user. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { + /// Use the elevated helper binary to enable the Windows sandbox. Elevated, + /// Fall back to the legacy setup flow for the Windows sandbox. Legacy, } +/// Reason a Windows sandbox elevation attempt failed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxFallbackReason { + /// Elevation failed and the UI must fall back to a non-elevated flow. ElevationFailed, } +/// High-level UI events processed by the main app loop. #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { + /// Deliver a backend protocol event to the UI. CodexEvent(Event), + + /// Forward an approval request from an external thread. ExternalApprovalRequest { + /// Thread that originated the approval request. thread_id: ThreadId, + + /// Original protocol event for the external approval. event: Event, }, @@ -80,20 +92,29 @@ pub(crate) enum AppEvent { /// original search term so the UI can decide whether the results are /// still relevant. FileSearchResult { + /// Search query that produced these results. query: String, + + /// Matching file paths for the query. matches: Vec, }, - /// Result of refreshing rate limits + /// Result of refreshing rate limits. RateLimitSnapshotFetched(RateLimitSnapshot), /// Result of computing a `/diff` command. DiffResult(String), + /// Insert a pre-rendered history cell into the transcript. InsertHistoryCell(Box), + /// Start emitting periodic commit tick events for animations. StartCommitAnimation, + + /// Stop emitting periodic commit tick events. StopCommitAnimation, + + /// Periodic animation tick used by shimmers and spinners. CommitTick, /// Update the current reasoning effort in the running app and widget. @@ -104,22 +125,28 @@ pub(crate) enum AppEvent { /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { + /// Selected model slug to persist. model: String, + + /// Selected reasoning effort to persist. effort: Option, }, /// Open the reasoning selection popup after picking a model. OpenReasoningPopup { + /// Model preset selected before choosing a reasoning effort. model: ModelPreset, }, /// Open the full model picker (non-auto models). OpenAllModelsPopup { + /// Available presets to display in the picker. models: Vec, }, /// Open the confirmation prompt before enabling full access mode. OpenFullAccessConfirmation { + /// Approval preset that will be applied if the user confirms. preset: ApprovalPreset, }, @@ -130,10 +157,13 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWorldWritableWarningConfirmation { preset: Option, + /// Up to 3 sample world-writable directories to display in the warning. sample_paths: Vec, + /// If there are more than `sample_paths`, this carries the remaining count. extra_count: usize, + /// True when the scan failed (e.g. ACL query error) and protections could not be verified. failed_scan: bool, }, @@ -141,26 +171,34 @@ pub(crate) enum AppEvent { /// Prompt to enable the Windows sandbox feature before using Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxEnablePrompt { + /// Approval preset to apply if the sandbox is enabled. preset: ApprovalPreset, }, /// Open the Windows sandbox fallback prompt after declining or failing elevation. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxFallbackPrompt { + /// Approval preset to apply if the fallback is accepted. preset: ApprovalPreset, + + /// Why the fallback prompt is required. reason: WindowsSandboxFallbackReason, }, /// Begin the elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxElevatedSetup { + /// Approval preset to apply after elevated setup completes. preset: ApprovalPreset, }, /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { + /// Approval preset to apply after enabling the sandbox. preset: ApprovalPreset, + + /// Setup mode to use when enabling the sandbox. mode: WindowsSandboxEnableMode, }, @@ -172,6 +210,7 @@ pub(crate) enum AppEvent { /// Update feature flags and persist them to the top-level config. UpdateFeatureFlags { + /// Feature enablement updates to apply and persist. updates: Vec<(Feature, bool)>, }, @@ -197,7 +236,10 @@ pub(crate) enum AppEvent { /// Persist the acknowledgement flag for the model migration prompt. PersistModelMigrationPromptAcknowledged { + /// Model slug the user is migrating from. from_model: String, + + /// Model slug the user is migrating to. to_model: String, }, @@ -222,12 +264,16 @@ pub(crate) enum AppEvent { /// Open the feedback note entry overlay after the user selects a category. OpenFeedbackNote { + /// Category chosen for the feedback note. category: FeedbackCategory, + + /// Whether to include logs when submitting feedback. include_logs: bool, }, /// Open the upload consent popup for feedback after selecting a category. OpenFeedbackConsent { + /// Category chosen for the feedback consent flow. category: FeedbackCategory, }, @@ -244,6 +290,7 @@ pub(crate) enum AppEvent { pub(crate) enum ExitMode { /// Shutdown core and exit after completion. ShutdownFirst, + /// Exit the UI loop immediately without waiting for shutdown. /// /// This skips `Op::Shutdown`, so any in-flight work may be dropped and @@ -251,10 +298,18 @@ pub(crate) enum ExitMode { Immediate, } +/// Categories used by the feedback UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FeedbackCategory { + /// User wants to report a bad result. BadResult, + + /// User wants to report a good result. GoodResult, + + /// User wants to report a bug or defect. Bug, + + /// User feedback that doesn't match other categories. Other, } diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index c1427b3ff0..3eec4ba0cd 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -1,20 +1,28 @@ +//! App event channel wrapper with logging semantics. +//! +//! `AppEventSender` centralizes how UI components send [`AppEvent`] messages to +//! the main loop, ensuring session logging stays consistent and send failures +//! are surfaced without panicking. + use tokio::sync::mpsc::UnboundedSender; use crate::app_event::AppEvent; use crate::session_log; +/// Lightweight handle for sending [`AppEvent`] values to the app loop. #[derive(Clone, Debug)] pub(crate) struct AppEventSender { + /// Channel used to deliver events to the main loop. pub app_event_tx: UnboundedSender, } impl AppEventSender { + /// Wrap the provided channel for consistent app event delivery. pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { Self { app_event_tx } } - /// Send an event to the app event channel. If it fails, we swallow the - /// error and log it. + /// Send an event to the app event channel, logging failures instead of panicking. pub(crate) fn send(&self, event: AppEvent) { // Record inbound events for high-fidelity session replay. // Avoid double-logging Ops; those are logged at the point of submission. diff --git a/codex-rs/tui/src/ascii_animation.rs b/codex-rs/tui/src/ascii_animation.rs index b2d9fc1d19..61447fcfb8 100644 --- a/codex-rs/tui/src/ascii_animation.rs +++ b/codex-rs/tui/src/ascii_animation.rs @@ -1,3 +1,9 @@ +//! Shared ASCII animation state for popups and onboarding. +//! +//! The animation tracks elapsed time and asks the [`FrameRequester`] to redraw +//! at the next frame boundary. Callers can switch between preloaded variants +//! without resetting the timing model. + use std::convert::TryFrom; use std::time::Duration; use std::time::Instant; @@ -10,18 +16,32 @@ use crate::tui::FrameRequester; /// Drives ASCII art animations shared across popups and onboarding widgets. pub(crate) struct AsciiAnimation { + /// Frame scheduler used to request UI redraws. request_frame: FrameRequester, + + /// Immutable list of frame variants (each variant is a list of frames). variants: &'static [&'static [&'static str]], + + /// Index into `variants` selecting the active animation. variant_idx: usize, + + /// Duration between frame advances. frame_tick: Duration, + + /// Start time used to compute frame offsets. start: Instant, } impl AsciiAnimation { + /// Build an animation using the shared variants and default frame tick. pub(crate) fn new(request_frame: FrameRequester) -> Self { Self::with_variants(request_frame, ALL_VARIANTS, 0) } + /// Build an animation with the provided variants and initial index. + /// + /// The start time is initialized immediately; callers can change variants later without + /// resetting the timing model. pub(crate) fn with_variants( request_frame: FrameRequester, variants: &'static [&'static [&'static str]], @@ -41,6 +61,7 @@ impl AsciiAnimation { } } + /// Schedule a redraw at the next frame boundary based on `frame_tick`. pub(crate) fn schedule_next_frame(&self) { let tick_ms = self.frame_tick.as_millis(); if tick_ms == 0 { @@ -62,6 +83,7 @@ impl AsciiAnimation { } } + /// Return the current frame string for the active variant. pub(crate) fn current_frame(&self) -> &'static str { let frames = self.frames(); if frames.is_empty() { @@ -76,6 +98,9 @@ impl AsciiAnimation { frames[idx] } + /// Pick a new random variant and schedule a redraw if one exists. + /// + /// Returns `false` when there is only one variant, leaving state unchanged. pub(crate) fn pick_random_variant(&mut self) -> bool { if self.variants.len() <= 1 { return false; @@ -90,11 +115,13 @@ impl AsciiAnimation { true } + /// Request an immediate frame redraw without changing animation state. #[allow(dead_code)] pub(crate) fn request_frame(&self) { self.request_frame.schedule_frame(); } + /// Return the frame list for the current variant. fn frames(&self) -> &'static [&'static str] { self.variants[self.variant_idx] } diff --git a/codex-rs/tui/src/bin/md-events.rs b/codex-rs/tui/src/bin/md-events.rs index f1117fad91..657f2102ce 100644 --- a/codex-rs/tui/src/bin/md-events.rs +++ b/codex-rs/tui/src/bin/md-events.rs @@ -1,6 +1,12 @@ +//! Debug helper that prints pulldown-cmark events for stdin. +//! +//! Feed markdown on stdin and this tool will emit the parsed event stream so +//! developers can inspect how pulldown-cmark tokenizes input. + use std::io::Read; use std::io::{self}; +/// Read markdown from stdin and dump the parsed event stream. fn main() { let mut input = String::new(); if let Err(err) = io::stdin().read_to_string(&mut input) { diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 0f0445fee8..593c3d926a 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -1,3 +1,19 @@ +//! Approval overlay for user-gated actions. +//! +//! This module renders modal approval prompts for exec, patch, and MCP elicitation requests. +//! It owns the short-lived state needed to present one request at a time, queue additional +//! requests, emit the matching `AppEvent` decisions, and advance only after each request is +//! explicitly accepted or rejected by the user. +//! +//! The overlay is implemented as a `BottomPaneView` backed by a selection list, so approvals +//! remain in a single modal while status timers are paused. It does not execute commands, +//! apply patches, or persist approval policy; it only renders the prompt and forwards the +//! user's decision to the rest of the app. +//! +//! The queue is treated as a stack (`Vec::push` + `pop`), so the most recently enqueued +//! approval is presented next. Correctness relies on keeping `current_request`, +//! `current_variant`, and the `options` list in sync, and on marking `current_complete` +//! before advancing to prevent double-emitting decisions. use std::collections::HashMap; use std::path::PathBuf; @@ -37,41 +53,92 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; /// Request coming from the agent that needs user approval. +/// +/// Each variant carries the context needed to render the approval prompt and to emit the +/// corresponding decision back to the core protocol once the user chooses an option. #[derive(Clone, Debug)] pub(crate) enum ApprovalRequest { + /// Exec request requiring user confirmation. Exec { + /// Backend request id used for correlating the approval. id: String, + + /// Command and arguments to show and approve. command: Vec, + + /// Optional human-readable reason shown to the user. reason: Option, + + /// Optional policy amendment to apply when approving. proposed_execpolicy_amendment: Option, }, + /// Patch application request requiring user confirmation. ApplyPatch { + /// Backend request id used for correlating the approval. id: String, + + /// Optional human-readable reason shown to the user. reason: Option, + + /// Working directory used to render file paths. cwd: PathBuf, + + /// File changes to summarize in the approval overlay. changes: HashMap, }, + /// MCP elicitation request requiring explicit user consent. McpElicitation { + /// MCP server name that issued the request. server_name: String, + + /// Request id used by the MCP server for correlation. request_id: RequestId, + + /// Prompt message to display in the overlay. message: String, }, } /// Modal overlay asking the user to approve or deny one or more requests. +/// +/// The overlay owns the selection list state and advances through a queue of requests, ensuring +/// each approval is resolved before the next one is shown. It keeps rendering-specific state +/// (`ApprovalVariant`, `ApprovalOption`) derived from the current request so the UI stays +/// responsive while the decision is pending. pub(crate) struct ApprovalOverlay { + /// Request currently shown to the user. current_request: Option, + + /// Render-specific view of the current request. current_variant: Option, + + /// Pending approvals queued behind the current one, treated as a stack. queue: Vec, + + /// App event channel used to emit approval decisions and history entries. app_event_tx: AppEventSender, + + /// List view used to render and navigate approval options. list: ListSelectionView, + + /// Option metadata for the current request, kept in sync with `current_variant`. options: Vec, + + /// Whether the current request has already been handled. current_complete: bool, + + /// Whether the overlay has completed all queued requests. done: bool, + + /// Feature flag snapshot used to conditionally show options. features: Features, } impl ApprovalOverlay { + /// Create a new overlay seeded with the first approval request. + /// + /// The selection view is rebuilt from the request so the overlay is immediately ready to + /// render without additional setup. pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { let mut view = Self { current_request: None, @@ -88,10 +155,17 @@ impl ApprovalOverlay { view } + /// Append an additional approval request to the queue. + /// + /// Requests are served in last-in-first-out order to ensure the newest prompt is shown next. pub fn enqueue_request(&mut self, req: ApprovalRequest) { self.queue.push(req); } + /// Make a request the active one and rebuild the option list. + /// + /// This resets completion tracking and re-seeds the selection list with the options derived + /// from the incoming request. fn set_current(&mut self, request: ApprovalRequest) { self.current_request = Some(request.clone()); let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); @@ -102,6 +176,9 @@ impl ApprovalOverlay { self.list = ListSelectionView::new(params, self.app_event_tx.clone()); } + /// Build the options list and selection view params for a request variant. + /// + /// The header combines a shared title with the variant-specific renderable content. fn build_options( variant: ApprovalVariant, header: Box, @@ -159,6 +236,10 @@ impl ApprovalOverlay { (options, params) } + /// Apply the selected option and advance the queue. + /// + /// The current request is marked complete before moving on so repeated selections do not + /// double-emit decisions. fn apply_selection(&mut self, actual_idx: usize) { if self.current_complete { return; @@ -191,6 +272,9 @@ impl ApprovalOverlay { self.advance_queue(); } + /// Emit a decision for an exec approval request. + /// + /// Exec decisions also create a history cell so the user can see the outcome in the log. fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); @@ -200,6 +284,7 @@ impl ApprovalOverlay { })); } + /// Emit a decision for a patch approval request. fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { id: id.to_string(), @@ -207,6 +292,7 @@ impl ApprovalOverlay { })); } + /// Emit a decision for an MCP elicitation request. fn handle_elicitation_decision( &self, server_name: &str, @@ -221,6 +307,9 @@ impl ApprovalOverlay { })); } + /// Advance to the next queued request, or mark the overlay done. + /// + /// The queue is treated as a stack, so the most recently enqueued request is shown next. fn advance_queue(&mut self) { if let Some(next) = self.queue.pop() { self.set_current(next); @@ -229,6 +318,10 @@ impl ApprovalOverlay { } } + /// Check key bindings and apply any matching option shortcut. + /// + /// Ctrl+A opens the full-screen approval view; all other matches map to the configured + /// per-option shortcuts. fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { match key_event { KeyEvent { @@ -262,6 +355,7 @@ impl ApprovalOverlay { } impl BottomPaneView for ApprovalOverlay { + /// Handle keyboard input routed to the overlay. fn handle_key_event(&mut self, key_event: KeyEvent) { if self.try_handle_shortcut(&key_event) { return; @@ -272,6 +366,10 @@ impl BottomPaneView for ApprovalOverlay { } } + /// Handle Ctrl+C by cancelling the overlay. + /// + /// If a request is active, the cancellation emits the corresponding abort decision before + /// clearing the rest of the queue. fn on_ctrl_c(&mut self) -> CancellationEvent { if self.done { return CancellationEvent::Handled; @@ -303,10 +401,12 @@ impl BottomPaneView for ApprovalOverlay { CancellationEvent::Handled } + /// Return true once all approvals are complete. fn is_complete(&self) -> bool { self.done } + /// Queue a new approval request while the overlay is active. fn try_consume_approval_request( &mut self, request: ApprovalRequest, @@ -317,25 +417,39 @@ impl BottomPaneView for ApprovalOverlay { } impl Renderable for ApprovalOverlay { + /// Return the height required to render the overlay. fn desired_height(&self, width: u16) -> u16 { self.list.desired_height(width) } + /// Render the overlay into the given buffer region. fn render(&self, area: Rect, buf: &mut Buffer) { self.list.render(area, buf); } + /// Return the cursor position for the overlay, if any. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.list.cursor_pos(area) } } +/// Precomputed rendering state derived from an approval request. +/// +/// This bundles the renderable header with the simplified approval variant used to build +/// option labels and shortcuts. struct ApprovalRequestState { + /// Renderable view of the request. variant: ApprovalVariant, + + /// Header content shown above the option list. header: Box, } impl From for ApprovalRequestState { + /// Build a renderable state from the incoming request. + /// + /// The header includes the optional reason, a preview of the command or diff, and any + /// prompting text needed for the active variant. fn from(value: ApprovalRequest) -> Self { match value { ApprovalRequest::Exec { @@ -409,37 +523,70 @@ impl From for ApprovalRequestState { } } +/// Render-specific view of an approval request. +/// +/// This variant strips out data that is only needed for the header rendering so option building +/// can focus on the decision payload. #[derive(Clone)] enum ApprovalVariant { + /// Exec approval with command preview. Exec { + /// Backend request id for correlating decisions. id: String, + + /// Command to display and approve. command: Vec, + + /// Optional execpolicy amendment suggested with the request. proposed_execpolicy_amendment: Option, }, + /// Patch approval with diff summary. ApplyPatch { + /// Backend request id for correlating decisions. id: String, }, + /// MCP elicitation approval. McpElicitation { + /// MCP server name associated with the request. server_name: String, + + /// Request id used by the MCP server for correlation. request_id: RequestId, }, } +/// Decision payload emitted when an option is selected. +/// +/// Each variant matches the protocol-level decision type for its approval flow. #[derive(Clone)] enum ApprovalDecision { + /// Standard approve/deny decision used for exec and patch requests. Review(ReviewDecision), + + /// Elicitation response for MCP prompts. McpElicitation(ElicitationAction), } +/// A single selectable option within an approval prompt. +/// +/// Options map user-visible labels and shortcuts to the protocol decision they should emit. #[derive(Clone)] struct ApprovalOption { + /// Label shown in the selection list. label: String, + + /// Decision emitted when the option is selected. decision: ApprovalDecision, + + /// Primary shortcut displayed next to the option, if any. display_shortcut: Option, + + /// Additional shortcuts that trigger the option. additional_shortcuts: Vec, } impl ApprovalOption { + /// Iterate over all shortcuts that should activate this option. fn shortcuts(&self) -> impl Iterator + '_ { self.display_shortcut .into_iter() @@ -447,6 +594,10 @@ impl ApprovalOption { } } +/// Build options for exec approvals, including optional policy amendments. +/// +/// The optional execpolicy amendment is only offered when the feature flag is enabled and the +/// command prefix can be rendered as a single line. fn exec_options( proposed_execpolicy_amendment: Option, features: &Features, @@ -490,6 +641,10 @@ fn exec_options( .collect() } +/// Build options for patch approvals. +/// +/// Patch approvals include a session-scoped approval option so the user can suppress follow-up +/// prompts for the same set of files. fn patch_options() -> Vec { vec![ ApprovalOption { @@ -513,6 +668,9 @@ fn patch_options() -> Vec { ] } +/// Build options for MCP elicitation approvals. +/// +/// These options map directly to the `ElicitationAction` variants expected by the core protocol. fn elicitation_options() -> Vec { vec![ ApprovalOption { @@ -536,6 +694,7 @@ fn elicitation_options() -> Vec { ] } +/// Tests for approval overlay behavior and shortcuts. #[cfg(test)] mod tests { use super::*; @@ -543,6 +702,7 @@ mod tests { use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + /// Build a basic exec approval request for tests. fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { id: "test".to_string(), @@ -552,6 +712,7 @@ mod tests { } } + /// Ctrl+C cancels the overlay and clears pending requests. #[test] fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); @@ -563,6 +724,7 @@ mod tests { assert!(view.is_complete()); } + /// Shortcut keys trigger the expected approval selection. #[test] fn shortcut_triggers_selection() { let (tx, mut rx) = unbounded_channel::(); @@ -570,6 +732,7 @@ mod tests { let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + // We expect at least one CodexOp message in the queue. let mut saw_op = false; while let Ok(ev) = rx.try_recv() { @@ -581,6 +744,7 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + /// Exec policy shortcut emits an approval with the proposed amendment. #[test] fn exec_prefix_option_emits_execpolicy_amendment() { let (tx, mut rx) = unbounded_channel::(); @@ -619,6 +783,7 @@ mod tests { ); } + /// Exec policy shortcut is hidden when the feature flag is disabled. #[test] fn exec_prefix_option_hidden_when_execpolicy_disabled() { let (tx, mut rx) = unbounded_channel::(); @@ -645,6 +810,7 @@ mod tests { assert!(rx.try_recv().is_err()); } + /// Renders a command snippet in the approval header. #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -676,6 +842,7 @@ mod tests { ); } + /// Wraps exec approval history cells with the expected indentation. #[test] fn exec_history_cell_wraps_with_two_space_indent() { let command = vec![ @@ -703,6 +870,7 @@ mod tests { assert_eq!(rendered, expected); } + /// Enter selects the default option without dismissing queued approvals. #[test] fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 499801cbb0..610701eb7e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,33 +1,55 @@ +//! Contracts for modal views hosted inside the bottom pane overlay stack. +//! +//! Bottom-pane views temporarily replace the composer while they are active, and the bottom pane +//! drives their lifecycle by routing input, rendering them, and removing them when they report +//! completion. Each view owns its own state, may intercept paste or cancellation input, and +//! chooses whether to consume approval requests or forward them, which lets a single overlay +//! coordinate multi-step prompts before the stack advances. use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; use crossterm::event::KeyEvent; use super::CancellationEvent; -/// Trait implemented by every view that can be shown in the bottom pane. +/// Defines the behavior every bottom-pane overlay must provide. +/// +/// Implementations handle input routing, track completion state, and optionally consume approval +/// requests so a single overlay can manage multiple related prompts. The bottom pane owns the +/// lifecycle and will drop the view once it reports completion. pub(crate) trait BottomPaneView: Renderable { - /// Handle a key event while the view is active. A redraw is always - /// scheduled after this call. + /// Handle a key event while the view is active. + /// + /// The bottom pane always schedules a redraw after this call, so handlers should update local + /// state and set completion flags as needed instead of requesting rendering directly. fn handle_key_event(&mut self, _key_event: KeyEvent) {} - /// Return `true` if the view has finished and should be removed. + /// Report whether the view has finished and should be removed. + /// + /// Implementations should treat completion as a terminal state; once `true`, the view must be + /// safe to drop and should continue to return `true` on subsequent checks. fn is_complete(&self) -> bool { false } /// Handle Ctrl-C while this view is active. + /// + /// Returning [`CancellationEvent::Handled`] prevents the bottom pane from applying its default + /// cancellation behavior, which allows overlays to intercept the key and clean up their state. fn on_ctrl_c(&mut self) -> CancellationEvent { CancellationEvent::NotHandled } - /// Optional paste handler. Return true if the view modified its state and - /// needs a redraw. + /// Handle pasted text when this view is active. + /// + /// Return `true` when the paste mutates state so the caller can schedule a redraw. fn handle_paste(&mut self, _pasted: String) -> bool { false } - /// Try to handle approval request; return the original value if not - /// consumed. + /// Offer an approval request to the view before it is routed elsewhere. + /// + /// Return `None` to consume the request and retain it in local state, or `Some(request)` to + /// forward the request unchanged to the next overlay. fn try_consume_approval_request( &mut self, request: ApprovalRequest, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 343403704e..545ce4ecfa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -126,6 +126,7 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +/// Return true when the Windows sandbox is in degraded (non-elevated) mode. fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -140,76 +141,165 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { + /// The input was submitted as a user message. Submitted(String), + + /// The input should be queued until the current task finishes. Queued(String), + + /// A slash command was invoked without arguments. Command(SlashCommand), + + /// A slash command was invoked with trailing arguments. CommandWithArgs(SlashCommand, String), + + /// No submission or command was produced. None, } +/// Image attachment tracked alongside its placeholder in the buffer. #[derive(Clone, Debug, PartialEq)] struct AttachedImage { + /// Placeholder text inserted into the editor buffer. placeholder: String, + + /// Absolute or workspace-relative file path for the image. path: PathBuf, } +/// How the prompt selection popup should apply a chosen entry. enum PromptSelectionMode { + /// Insert the prompt and keep editing. Completion, + + /// Insert the prompt and submit immediately. Submit, } +/// Action returned when a prompt selection is applied. enum PromptSelectionAction { + /// Insert text at the cursor and optionally reposition it. Insert { text: String, cursor: Option }, + + /// Insert text and submit it as a message. Submit { text: String }, } +/// Editable input state machine for the bottom-pane composer. +/// +/// Owns the textarea buffer, popup state, and per-session history, while emitting +/// [`AppEvent`]s to request side effects (file search, prompt expansion, etc.). +/// It also tracks transient UI state such as paste bursts, attachment placeholders, +/// and footer hints so rendering can stay derived from this central state. pub(crate) struct ChatComposer { + /// Editable text area for composer input. textarea: TextArea, + + /// Shared state used to render and measure the textarea. textarea_state: RefCell, + + /// Currently active popup (slash commands, file search, skills). active_popup: ActivePopup, + + /// Channel for emitting composer-driven app events. app_event_tx: AppEventSender, + + /// History buffer for cycling prior input. history: ChatComposerHistory, + + /// Deadline for the double-press quit shortcut. quit_shortcut_expires_at: Option, + + /// Key used to arm the quit shortcut (Ctrl+C vs Ctrl+D). quit_shortcut_key: KeyBinding, + + /// Whether the footer should show the Esc backtrack hint. esc_backtrack_hint: bool, + + /// Whether to show the Shift+Enter hint in the footer. use_shift_enter_hint: bool, + + /// Token used to suppress reopening the file popup after dismissal. dismissed_file_popup_token: Option, + + /// Current file-search query driving the file popup. current_file_query: Option, + + /// Pending large paste placeholders mapped to full text content. pending_pastes: Vec<(String, String)>, + + /// Counters used to generate unique placeholders per paste size. large_paste_counters: HashMap, + + /// Whether the composer currently owns focus. has_focus: bool, + + /// Attached images represented by placeholder text. attached_images: Vec, + + /// Placeholder string shown when the composer is empty. placeholder_text: String, + + /// Whether a task is currently running (affects submit/queue behavior). is_task_running: bool, + /// When false, the composer is temporarily read-only (e.g. during sandbox setup). input_enabled: bool, + + /// Placeholder shown while input is disabled. input_disabled_placeholder: Option, + /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. + + /// When true, bypasses paste-burst logic and inserts characters immediately. disable_paste_burst: bool, + + /// Custom prompts list used by the slash command popup. custom_prompts: Vec, + + /// Current footer display mode. footer_mode: FooterMode, + + /// Optional footer hint override. footer_hint_override: Option>, + + /// Cached context window percent for status display. context_window_percent: Option, + + /// Cached context window token usage for status display. context_window_used_tokens: Option, + + /// Skills available for mention popup. skills: Option>, + + /// Token used to suppress reopening the skill popup after dismissal. dismissed_skill_popup_token: Option, + /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, } /// Popup state – at most one can be visible at any time. enum ActivePopup { + /// No popup is active. None, + + /// Slash command selection popup. Command(CommandPopup), + + /// File search popup for `@file` mentions. File(FileSearchPopup), + + /// Skill mention popup. Skill(SkillPopup), } +/// Extra spacing row inserted between the composer and footer (when enabled). const FOOTER_SPACING_HEIGHT: u16 = 0; impl ChatComposer { + /// Create a new chat composer with the given initial state. pub fn new( has_input_focus: bool, app_event_tx: AppEventSender, @@ -250,11 +340,13 @@ impl ChatComposer { dismissed_skill_popup_token: None, steer_enabled: false, }; + // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + /// Replace the available skills for mention popup rendering. pub fn set_skill_mentions(&mut self, skills: Option>) { self.skills = skills; } @@ -269,6 +361,7 @@ impl ChatComposer { self.steer_enabled = enabled; } + /// Compute composer, footer, and popup layout regions within the given area. fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -292,6 +385,7 @@ impl ChatComposer { [composer_rect, textarea_rect, popup_rect] } + /// Return the spacing row count between composer and footer. fn footer_spacing(footer_hint_height: u16) -> u16 { if footer_hint_height == 0 { 0 @@ -356,12 +450,16 @@ impl ChatComposer { } else { self.textarea.insert_str(&pasted); } + // Explicit paste events should not trigger Enter suppression. self.paste_burst.clear_after_explicit_paste(); self.sync_popups(); true } + /// Try to interpret a pasted string as an image path and attach it. + /// + /// Returns `true` if the path resolves to a readable image and is attached. pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { let Some(path_buf) = normalize_pasted_path(&pasted) else { return false; @@ -483,6 +581,9 @@ impl ChatComposer { self.sync_popups(); } + /// Return the current text with any large-paste placeholders expanded. + /// + /// This is used when submitting input so large paste buffers are restored. pub(crate) fn current_text_with_pending(&self) -> String { let mut text = self.textarea.text().to_string(); for (placeholder, actual) in &self.pending_pastes { @@ -510,6 +611,7 @@ impl ChatComposer { self.sync_popups(); } + /// Clear the composer in response to Ctrl+C and record the draft in history. pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; @@ -530,6 +632,7 @@ impl ChatComposer { pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; let placeholder = local_image_label_text(image_number); + // Insert as an element to match large paste placeholder behavior: // styled distinctly and treated atomically for cursor/mutations. self.textarea.insert_element(&placeholder); @@ -537,6 +640,7 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + /// Drain attached images so they can be sent with the next submission. pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() @@ -619,6 +723,9 @@ impl ChatComposer { .is_some_and(|expires_at| Instant::now() < expires_at) } + /// Generate a unique placeholder label for a large paste buffer. + /// + /// Suffixes are incremented per character count to avoid collisions. fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { let base = format!("[Pasted Content {char_count} chars]"); let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); @@ -630,6 +737,7 @@ impl ChatComposer { } } + /// Insert text at the cursor and refresh popup state. pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_popups(); @@ -805,6 +913,7 @@ impl ChatComposer { } } } + // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) } @@ -813,6 +922,7 @@ impl ChatComposer { } #[inline] + /// Clamp a byte offset to the nearest valid UTF-8 character boundary. fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { let mut p = pos.min(text.len()); if p < text.len() && !text.is_char_boundary(p) { @@ -860,6 +970,7 @@ impl ChatComposer { if self.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } + // Non-ASCII input often comes from IMEs and can arrive in quick bursts. // We do not want to hold the first char (flicker suppression) on this path, but we // still want to detect paste-like bursts. Before applying any non-ASCII input, flush @@ -889,10 +1000,12 @@ impl ChatComposer { if !grab.grabbed.is_empty() { self.textarea.replace_range(grab.start_byte..safe_cur, ""); } + // seed the paste burst buffer with everything (grabbed + new) self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } + // If decide_begin_buffer opted not to start buffering, // fall through to normal insertion below. } @@ -976,6 +1089,7 @@ impl ChatComposer { }; let sel_path = sel.to_string(); + // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. let is_image = Self::is_image_path(&sel_path); if is_image { @@ -984,10 +1098,12 @@ impl ChatComposer { match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); + // Remove the current @token (mirror logic from insert_selected_path without inserting text) // using the flat text and byte-offset cursor API. let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -1010,11 +1126,13 @@ impl ChatComposer { self.textarea.set_cursor(start_idx); self.attach_image(path_buf); + // Add a trailing space to keep typing fluid. self.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); + // Fallback to plain path insertion if metadata read fails. self.insert_selected_path(&sel_path); } @@ -1023,6 +1141,7 @@ impl ChatComposer { // Non-image: inserting file path. self.insert_selected_path(&sel_path); } + // No selection: treat Enter as closing the popup/session. self.active_popup = ActivePopup::None; (InputResult::None, true) @@ -1031,6 +1150,7 @@ impl ChatComposer { } } + /// Handle key input when the skill mention popup is active. fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); @@ -1093,15 +1213,18 @@ impl ChatComposer { } } + /// Return true if the path looks like a supported image file. fn is_image_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + /// Return true when skill mentions are enabled and populated. fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } + /// Return the active skill list, if configured. pub fn skills(&self) -> Option<&Vec> { self.skills.as_ref() } @@ -1127,6 +1250,7 @@ impl ChatComposer { // Adjust the provided byte offset to the nearest valid char boundary at or before it. let mut safe_cursor = cursor_offset.min(text.len()); + // If we're not on a char boundary, move back to the start of the current char. if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { // Find the last valid boundary <= cursor_offset. @@ -1219,6 +1343,7 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', false) } + /// Extract the `$token` under the cursor for skill completion, if any. fn current_skill_token(&self) -> Option { if !self.skills_enabled() { return None; @@ -1234,6 +1359,7 @@ impl ChatComposer { fn insert_selected_path(&mut self, path: &str) { let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); + // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -1277,6 +1403,7 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + /// Replace the active `$token` under the cursor with a selected skill name. fn insert_selected_skill(&mut self, skill_name: &str) { let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); @@ -1529,6 +1656,7 @@ impl ChatComposer { kind: KeyEventKind::Press, .. } if self.is_empty() => (InputResult::None, false), + // ------------------------------------------------------------- // History navigation (Up / Down) – only when the composer is not // empty or when the cursor is at the correct position, to avoid @@ -1676,6 +1804,7 @@ impl ChatComposer { self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } + // If decide_begin_buffer opted not to start buffering, // fall through to normal insertion below. } @@ -1706,6 +1835,7 @@ impl ChatComposer { { self.handle_paste(pasted); } + // Backspace at the start of an image placeholder should delete that placeholder (rather // than deleting content before it). Do this without scanning the full text by consulting // the textarea's element list. @@ -1742,6 +1872,7 @@ impl ChatComposer { } } KeyCode::Enter => { + // Keep burst window alive (supports blank lines in paste). } _ => { @@ -1753,6 +1884,7 @@ impl ChatComposer { (InputResult::None, true) } + /// Remove an image placeholder element that starts at the cursor, if present. fn try_remove_image_element_at_cursor_start(&mut self) -> bool { if self.attached_images.is_empty() { return false; @@ -1776,6 +1908,7 @@ impl ChatComposer { true } + /// Reconcile deleted placeholder elements and drop related attachments. fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = self.textarea.element_payloads().into_iter().collect(); @@ -1802,6 +1935,7 @@ impl ChatComposer { } } + /// Re-label attached images in order and update any placeholder elements. fn relabel_attached_images_and_update_placeholders(&mut self) { for idx in 0..self.attached_images.len() { let expected = local_image_label_text(idx + 1); @@ -1815,6 +1949,7 @@ impl ChatComposer { } } + /// Toggle the shortcut overlay when the user presses `?`. fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { if key_event.kind != KeyEventKind::Press { return false; @@ -1835,6 +1970,7 @@ impl ChatComposer { changed } + /// Build the footer props used to render the composer hint line. fn footer_props(&self) -> FooterProps { FooterProps { mode: self.footer_mode(), @@ -1848,6 +1984,7 @@ impl ChatComposer { } } + /// Compute the effective footer mode based on current state and hints. fn footer_mode(&self) -> FooterMode { match self.footer_mode { FooterMode::EscHint => FooterMode::EscHint, @@ -1864,17 +2001,20 @@ impl ChatComposer { } } + /// Return the height override for custom footer hints, if any. fn custom_footer_height(&self) -> Option { self.footer_hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) } + /// Refresh popup state based on the current text and cursor position. fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); let browsing_history = self .history .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); + // When browsing input history (shell-style Up/Down recall), skip all popup // synchronization so nothing steals focus from continued history navigation. if browsing_history { @@ -1975,6 +2115,7 @@ impl ChatComposer { } return; } + // Determine whether the caret is inside the initial '/name' token on the first line. let text = self.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); @@ -2015,6 +2156,7 @@ impl ChatComposer { } } + /// Replace the custom prompts list and update the command popup if active. pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { @@ -2058,6 +2200,7 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } + /// Synchronize the skill popup with the current `$` token query. fn sync_skill_popup(&mut self, query: String) { if self.dismissed_skill_popup_token.as_ref() == Some(&query) { return; @@ -2084,11 +2227,13 @@ impl ChatComposer { } } + /// Update focus state so footer hints can render appropriately. fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } #[allow(dead_code)] + /// Enable or disable input and update the placeholder accordingly. pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; @@ -2099,10 +2244,12 @@ impl ChatComposer { } } + /// Record whether a task is currently running to drive footer hints. pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; } + /// Update context window usage for footer display. pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens { @@ -2112,6 +2259,7 @@ impl ChatComposer { self.context_window_used_tokens = used_tokens; } + /// Show or hide the Esc backtrack hint and update footer mode. pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; if show { @@ -2123,6 +2271,7 @@ impl ChatComposer { } impl Renderable for ChatComposer { + /// Return the cursor position for the composer, if input is enabled. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if !self.input_enabled { return None; @@ -2133,6 +2282,7 @@ impl Renderable for ChatComposer { self.textarea.cursor_pos_with_state(textarea_rect, state) } + /// Return the height needed to render the composer and footer at the given width. fn desired_height(&self, width: u16) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self @@ -2152,6 +2302,7 @@ impl Renderable for ChatComposer { } } + /// Render the composer and footer into the provided buffer. fn render(&self, area: Rect, buf: &mut Buffer) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); match &self.active_popup { @@ -2236,6 +2387,10 @@ impl Renderable for ChatComposer { } } +/// Decide whether a prompt selection should insert placeholders or submit content. +/// +/// This inspects the prompt template to decide between immediate submission and placeholder +/// insertion, returning the appropriate action for the current selection mode. fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, @@ -2303,6 +2458,7 @@ mod tests { use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; + /// Ensure the footer hint row is separated from the composer content. #[test] fn footer_hint_row_is_separated_from_composer() { let (tx, _rx) = unbounded_channel::(); @@ -2357,6 +2513,7 @@ mod tests { ); } + /// Render the composer with a setup closure and snapshot its buffer. fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) where F: FnOnce(&mut ChatComposer), @@ -2386,6 +2543,7 @@ mod tests { insta::assert_snapshot!(name, terminal.backend()); } + /// Snapshot footer mode variants for the hint line. #[test] fn footer_mode_snapshots() { use crossterm::event::KeyCode; @@ -2438,6 +2596,7 @@ mod tests { }); } + /// Ensure the Esc hint does not appear when draft text is present. #[test] fn esc_hint_stays_hidden_with_draft_content() { use crossterm::event::KeyCode; @@ -2467,6 +2626,7 @@ mod tests { assert!(!composer.esc_backtrack_hint); } + /// Verify Ctrl+C clears the draft and records it in history. #[test] fn clear_for_ctrl_c_records_cleared_draft() { let (tx, _rx) = unbounded_channel::(); @@ -2585,6 +2745,7 @@ mod tests { assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); } + /// Ensure the shortcut overlay remains visible while a task is running. #[test] fn shortcut_overlay_persists_while_task_running() { use crossterm::event::KeyCode; @@ -2614,6 +2775,7 @@ mod tests { assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); } + /// Validate basic `@token` extraction cases. #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ @@ -2672,6 +2834,7 @@ mod tests { } } + /// Verify `@token` extraction at different cursor positions. #[test] fn test_current_at_token_cursor_positions() { let test_cases = vec![ @@ -2706,6 +2869,7 @@ mod tests { } } + /// Confirm `@token` extraction across whitespace boundaries. #[test] fn test_current_at_token_whitespace_boundaries() { let test_cases = vec![ @@ -3121,6 +3285,7 @@ mod tests { } } + /// Ensure Enter on empty input returns `InputResult::None`. #[test] fn empty_enter_returns_none() { use crossterm::event::KeyCode; @@ -3216,6 +3381,7 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + /// Snapshot various composer states for visual regression testing. #[test] fn ui_snapshots() { use crossterm::event::KeyCode; @@ -3254,8 +3420,10 @@ mod tests { } else if name == "multiple_pastes" { // First large paste composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); + // Second large paste composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); + // Small paste composer.handle_paste(" another short paste".to_string()); } else if name == "backspace_after_pastes" { @@ -3263,6 +3431,7 @@ mod tests { composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); + // Move cursor to end and press backspace composer.textarea.set_cursor(composer.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); @@ -3276,6 +3445,7 @@ mod tests { } } + /// Snapshot image placeholder rendering for single and multiple attachments. #[test] fn image_placeholder_snapshots() { snapshot_composer_state("image_placeholder_single", false, |composer| { @@ -3288,6 +3458,7 @@ mod tests { }); } + /// Ensure the `/mo` popup shows `/model` first in UI snapshots. #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; @@ -3320,6 +3491,7 @@ mod tests { insta::assert_snapshot!("slash_popup_mo", terminal.backend()); } + /// Ensure the `/mo` popup selects `/model` first in logic. #[test] fn slash_popup_model_first_for_mo_logic() { use super::super::command_popup::CommandItem; @@ -3349,6 +3521,7 @@ mod tests { } } + /// Ensure the `/res` popup shows `/resume` first in UI snapshots. #[test] fn slash_popup_resume_for_res_ui() { use ratatui::Terminal; @@ -3378,6 +3551,7 @@ mod tests { insta::assert_snapshot!("slash_popup_res", terminal.backend()); } + /// Ensure the `/res` popup selects `/resume` first in logic. #[test] fn slash_popup_resume_for_res_logic() { use super::super::command_popup::CommandItem; @@ -3406,12 +3580,13 @@ mod tests { } } + /// Flush a paste burst after the recommended delay in tests. fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { std::thread::sleep(PasteBurst::recommended_active_flush_delay()); composer.flush_paste_burst_if_due() } - // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer + /// Simulate human typing with delays so paste-burst heuristics do not trigger. fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -3423,6 +3598,7 @@ mod tests { } } + /// Verify `/init` dispatches a command instead of literal submission. #[test] fn slash_init_dispatches_command_and_does_not_submit_literal_text() { use crossterm::event::KeyCode; @@ -3466,6 +3642,7 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + /// Ensure quoted path args parse as a single positional argument. #[test] fn extract_args_supports_quoted_paths_single_arg() { let args = extract_positional_args_for_prompt_line( @@ -3475,6 +3652,7 @@ mod tests { assert_eq!(args, vec!["docs/My File.md".to_string()]); } + /// Ensure mixed quoted and unquoted args parse in order. #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { let args = @@ -3482,6 +3660,7 @@ mod tests { assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); } + /// Verify Tab completion moves the cursor to the end of the command. #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; @@ -3507,6 +3686,7 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + /// Verify Tab-completed commands dispatch on Enter. #[test] fn slash_tab_then_enter_dispatches_builtin_command() { let (tx, _rx) = unbounded_channel::(); @@ -3545,6 +3725,7 @@ mod tests { assert!(composer.textarea.is_empty()); } + /// Ensure `/mention` dispatches and inserts `@`. #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; @@ -3668,6 +3849,7 @@ mod tests { } } + /// Ensure deleting a placeholder drops its pending paste entry. #[test] fn test_placeholder_deletion() { use crossterm::event::KeyCode; @@ -3821,6 +4003,7 @@ mod tests { assert_eq!(composer.pending_pastes[1].0, third); } + /// Ensure partial deletion of a placeholder clears pending paste tracking. #[test] fn test_partial_placeholder_deletion() { use crossterm::event::KeyCode; @@ -3873,6 +4056,7 @@ mod tests { } // --- Image attachment tests --- + /// Ensure attached images are included in submitted paths. #[test] fn attach_image_and_submit_includes_image_paths() { let (tx, _rx) = unbounded_channel::(); @@ -3898,6 +4082,7 @@ mod tests { assert_eq!(vec![path], imgs); } + /// Ensure image-only submissions include images and empty text. #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -3924,6 +4109,7 @@ mod tests { assert!(composer.attached_images.is_empty()); } + /// Verify duplicate image placeholders are numbered with suffixes. #[test] fn duplicate_image_placeholders_get_suffix() { let (tx, _rx) = unbounded_channel::(); @@ -3947,6 +4133,7 @@ mod tests { assert_eq!(composer.attached_images[1].placeholder, "[Image #2]"); } + /// Ensure image placeholder backspace behavior mirrors text placeholders. #[test] fn image_placeholder_backspace_behaves_like_text_placeholder() { let (tx, _rx) = unbounded_channel::(); @@ -3972,6 +4159,7 @@ mod tests { // and drop the image mapping (same as text placeholder behavior). composer.attach_image(path); let placeholder2 = composer.attached_images[0].placeholder.clone(); + // Move cursor to roughly middle of placeholder if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { let mid_pos = start_pos + (placeholder2.len() / 2); @@ -3984,6 +4172,7 @@ mod tests { } } + /// Guard against panics when backspacing near multibyte text. #[test] fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { use crossterm::event::KeyCode; @@ -4003,6 +4192,7 @@ mod tests { // Insert an image placeholder at the start let path = PathBuf::from("/tmp/image_multibyte.png"); composer.attach_image(path); + // Add multibyte text after the placeholder composer.textarea.insert_str("日本語"); @@ -4014,6 +4204,7 @@ mod tests { assert!(composer.textarea.text().starts_with("[Image #1]")); } + /// Ensure deleting one of two image placeholders leaves one attachment. #[test] fn deleting_one_of_duplicate_image_placeholders_removes_one_entry() { let (tx, _rx) = unbounded_channel::(); @@ -4030,6 +4221,7 @@ mod tests { let path2 = PathBuf::from("/tmp/image_dup2.png"); composer.attach_image(path1); + // separate placeholders with a space for clarity composer.handle_paste(" ".into()); composer.attach_image(path2.clone()); @@ -4070,6 +4262,7 @@ mod tests { ); } + /// Ensure deleting the first element renumbers the remaining image placeholder. #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4106,6 +4299,7 @@ mod tests { assert_eq!(composer.textarea.text(), "[Image #1]"); } + /// Verify pasting a file path attaches the image and adds a placeholder. #[test] fn pasting_filepath_attaches_image() { let tmp = tempdir().expect("create TempDir"); @@ -4132,6 +4326,7 @@ mod tests { assert_eq!(imgs, vec![tmp_path]); } + /// Ensure selecting a prompt without args submits its content. #[test] fn selecting_custom_prompt_without_args_submits_content() { let prompt_text = "Hello from saved prompt"; @@ -4171,6 +4366,7 @@ mod tests { assert!(composer.textarea.is_empty()); } + /// Ensure custom prompt submissions expand named arguments. #[test] fn custom_prompt_submission_expands_arguments() { let (tx, _rx) = unbounded_channel::(); @@ -4206,6 +4402,7 @@ mod tests { assert!(composer.textarea.is_empty()); } + /// Ensure quoted values are accepted when expanding custom prompts. #[test] fn custom_prompt_submission_accepts_quoted_values() { let (tx, _rx) = unbounded_channel::(); @@ -4308,6 +4505,7 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + /// Ensure slash-prefixed paths submit as text, not commands. #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; @@ -4345,6 +4543,7 @@ mod tests { } } + /// Ensure leading-space slash text submits literally. #[test] fn slash_with_leading_space_submits_as_text() { use crossterm::event::KeyCode; @@ -4380,6 +4579,7 @@ mod tests { } } + /// Verify invalid prompt arguments trigger an error event. #[test] fn custom_prompt_invalid_args_reports_error() { let (tx, mut rx) = unbounded_channel::(); @@ -4430,6 +4630,7 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } + /// Verify missing required prompt arguments trigger an error event. #[test] fn custom_prompt_missing_required_args_reports_error() { let (tx, mut rx) = unbounded_channel::(); @@ -4480,6 +4681,7 @@ mod tests { ); } + /// Ensure prompts with args expand numeric placeholders and $ARGUMENTS. #[test] fn selecting_custom_prompt_with_args_expands_placeholders() { // Support $1..$9 and $ARGUMENTS in prompt content. @@ -4519,6 +4721,7 @@ mod tests { assert_eq!(InputResult::Submitted(expected), result); } + /// Ensure numeric placeholder prompts accept positional args without error. #[test] fn numeric_prompt_positional_args_does_not_error() { // Ensure that a prompt with only numeric placeholders does not trigger @@ -4551,6 +4754,7 @@ mod tests { assert!(composer.textarea.is_empty()); } + /// Ensure selecting a prompt with no args inserts the command template. #[test] fn selecting_custom_prompt_with_no_args_inserts_template() { let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; @@ -4586,6 +4790,7 @@ mod tests { assert_eq!("/prompts:p ", composer.textarea.text()); } + /// Ensure literal `$$` is preserved during prompt expansion. #[test] fn selecting_custom_prompt_preserves_literal_dollar_dollar() { // '$$' should remain untouched. @@ -4625,6 +4830,7 @@ mod tests { ); } + /// Ensure `$ARGUMENTS` expansion reuses cached joins. #[test] fn selecting_custom_prompt_reuses_cached_arguments_join() { let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; @@ -4795,6 +5001,7 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + /// Ensure slash popup does not open for "/ " history-like input. #[test] fn slash_popup_not_activated_for_slash_space_text_history_like_input() { use crossterm::event::KeyCode; @@ -4827,9 +5034,9 @@ mod tests { assert_eq!(result, InputResult::None); } + /// Ensure slash popup activates for valid prefixes and bare slash. #[test] fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { - // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::sync::mpsc::unbounded_channel; let (tx, _rx) = unbounded_channel::(); @@ -4873,6 +5080,7 @@ mod tests { ); } + /// Ensure external edits rebuild text and keep matching attachments. #[test] fn apply_external_edit_rebuilds_text_and_attachments() { let (tx, _rx) = unbounded_channel::(); @@ -4907,6 +5115,7 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.current_text().len()); } + /// Ensure external edits drop attachments whose placeholders are removed. #[test] fn apply_external_edit_drops_missing_attachments() { let (tx, _rx) = unbounded_channel::(); @@ -4932,6 +5141,7 @@ mod tests { assert!(composer.attached_images.is_empty()); } + /// Ensure pending placeholders expand to their original text. #[test] fn current_text_with_pending_expands_placeholders() { let (tx, _rx) = unbounded_channel::(); @@ -4957,6 +5167,7 @@ mod tests { ); } + /// Ensure external edits keep attachments only for matching placeholders. #[test] fn apply_external_edit_limits_duplicates_to_occurrences() { let (tx, _rx) = unbounded_channel::(); @@ -4985,6 +5196,7 @@ mod tests { assert_eq!(composer.attached_images.len(), 1); } + /// Ensure disabled input ignores keypresses and hides the cursor. #[test] fn input_disabled_ignores_keypresses_and_hides_cursor() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 991283a566..c413b38131 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -1,36 +1,57 @@ +//! Manages shell-style history navigation for the chat composer. +//! +//! This module keeps track of persistent history entries that are fetched +//! on-demand, plus the in-session submissions that have not been persisted +//! yet. It owns the navigation cursor and the last inserted history text so +//! callers can decide when Up/Down should navigate versus edit the composer. +//! Rendering and text area mutation live elsewhere; this module only returns +//! replacement text or issues history fetch requests through [`AppEventSender`]. + use std::collections::HashMap; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_core::protocol::Op; -/// State machine that manages shell-style history navigation (Up/Down) inside -/// the chat composer. This struct is intentionally decoupled from the -/// rendering widget so the logic remains isolated and easier to test. +/// Tracks history state and selection for chat-composer navigation. +/// +/// The history list is the concatenation of persistent entries (fetched by +/// offset) followed by the in-session submissions. The state machine owns the +/// navigation cursor and the last text inserted via history so it can decide +/// when Up/Down should browse rather than edit. Callers apply the returned text +/// to the composer and forward any history fetch requests to the backend. pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. + /// Identifier of the active history log, set by session configuration. + /// + /// This is echoed in `GetHistoryEntryResponse` to guard against stale + /// responses after a session change. history_log_id: Option, - /// Number of entries already present in the persistent cross-session - /// history file when the session started. + /// Count of persisted entries present when the session started. + /// + /// This value defines the boundary between persisted entries and + /// `local_history` in the combined index space. history_entry_count: usize, - /// Messages submitted by the user *during this UI session* (newest at END). + /// Messages submitted during this UI session, ordered from oldest to newest. local_history: Vec, - /// Cache of persistent history entries fetched on-demand. + /// Cache of persistent history entries keyed by their global offset. fetched_history: HashMap, - /// Current cursor within the combined (persistent + local) history. `None` - /// indicates the user is *not* currently browsing history. + /// Current cursor within the combined (persistent + local) history. + /// + /// `None` means the user is not currently browsing history. history_cursor: Option, - /// The text that was last inserted into the composer as a result of - /// history navigation. Used to decide if further Up/Down presses should be - /// treated as navigation versus normal cursor movement. + /// The text last inserted into the composer via history navigation. + /// + /// This is used to decide whether Up/Down should keep navigating or return + /// to normal editing. last_history_text: Option, } impl ChatComposerHistory { + /// Build an empty history state with no session metadata. pub fn new() -> Self { Self { history_log_id: None, @@ -42,7 +63,10 @@ impl ChatComposerHistory { } } - /// Update metadata when a new session is configured. + /// Reset the state for a newly configured session. + /// + /// This clears cached entries and local submissions because they are tied + /// to the previous log identifier. pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { self.history_log_id = Some(log_id); self.history_entry_count = entry_count; @@ -52,8 +76,10 @@ impl ChatComposerHistory { self.last_history_text = None; } - /// Record a message submitted by the user in the current session so it can - /// be recalled later. + /// Record a message submitted by the user so it can be recalled later. + /// + /// This clears any active navigation state and de-duplicates consecutive + /// identical entries. pub fn record_local_submission(&mut self, text: &str) { if text.is_empty() { return; @@ -70,14 +96,16 @@ impl ChatComposerHistory { self.local_history.push(text.to_string()); } - /// Reset navigation tracking so the next Up key resumes from the latest entry. + /// Clear navigation tracking so the next Up key resumes from the newest entry. pub fn reset_navigation(&mut self) { self.history_cursor = None; self.last_history_text = None; } - /// Should Up/Down key presses be interpreted as history navigation given - /// the current content and cursor position of `textarea`? + /// Decide whether Up/Down should navigate history for the current editor state. + /// + /// Navigation is allowed when the editor is empty, or when the cursor is at + /// the start and the editor still contains the last recalled history entry. pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { if self.history_entry_count == 0 && self.local_history.is_empty() { return false; @@ -97,8 +125,11 @@ impl ChatComposerHistory { matches!(&self.last_history_text, Some(prev) if prev == text) } - /// Handle . Returns true when the key was consumed and the caller - /// should request a redraw. + /// Handle an Up press and return replacement text if available. + /// + /// This advances the cursor to the previous entry and returns cached text + /// when present. If the entry is persistent and not cached, a fetch request + /// is sent and `None` is returned until the response arrives. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { @@ -115,7 +146,11 @@ impl ChatComposerHistory { self.populate_history_at_index(next_idx as usize, app_event_tx) } - /// Handle . + /// Handle a Down press and return replacement text if available. + /// + /// This advances the cursor toward newer entries. Moving past the newest + /// entry exits history browsing and returns an empty string to restore the + /// editor to its non-history state. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { @@ -142,7 +177,11 @@ impl ChatComposerHistory { } } - /// Integrate a GetHistoryEntryResponse event. + /// Integrate an async history entry response. + /// + /// The response is cached for future navigation. If it matches the current + /// cursor position, the entry is returned so the caller can update the + /// editor immediately. pub fn on_entry_response( &mut self, log_id: u64, @@ -162,10 +201,7 @@ impl ChatComposerHistory { None } - // --------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------- - + /// Resolve a history index to cached text or trigger an async fetch. fn populate_history_at_index( &mut self, global_idx: usize, @@ -199,6 +235,7 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_core::protocol::Op; + use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; #[test] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ec4e86af03..4961aae8ca 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -1,3 +1,28 @@ +//! Renders and filters the slash-command selection popup for the composer. +//! +//! This module builds the list of selectable slash commands shown beneath the +//! composer when a line begins with `/`. It merges built-in [`SlashCommand`] +//! entries with user-defined [`CustomPrompt`] items, filters them via fuzzy +//! matching, and exposes a [`WidgetRef`] renderer that delegates row layout to +//! [`selection_popup_common`]. +//! +//! The popup owns the active filter token and the scroll/selection state; it +//! does not execute commands or mutate composer text. When there is no filter, +//! built-ins appear first in presentation order, followed by user prompts sorted +//! by name. When a filter is active, matches are sorted by ascending fuzzy +//! score, then by command or prompt name for stability across runs. +//! +//! The list of built-ins is feature-gated to hide commands that the runtime +//! cannot support (for example, elevated sandbox toggles on Windows). Prompt +//! rows default to a generic description when prompt frontmatter is missing so +//! the layout stays stable even with sparse metadata. +//! +//! Custom prompt names that collide with built-in command names are excluded, +//! and the prompt display names always use the `/prompts:` prefix. Highlight +//! indices are computed against the non-slash display string and are shifted by +//! one when rendered to account for the leading `/`. +//! +//! [`selection_popup_common`]: super::selection_popup_common use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; @@ -15,6 +40,10 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; +/// Returns whether the Windows sandbox is present but not fully elevated. +/// +/// This is used to suppress the elevate-sandbox command when the degraded +/// sandbox flow should not be surfaced to the user. fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED @@ -25,19 +54,47 @@ fn windows_degraded_sandbox_active() -> bool { /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { + /// A built-in slash command defined by the TUI. Builtin(SlashCommand), - // Index into `prompts` + /// An index into [`CommandPopup::prompts`]. UserPrompt(usize), } +/// Owns the slash-command popup state and filtered command list. +/// +/// The popup keeps a mutable filter token derived from the composer input and +/// drives a [`ScrollState`] that tracks the current selection and scroll +/// window. Built-in commands are cached so the list can be filtered quickly and +/// user prompts are stored in sorted order for stable presentation. Selection +/// indices always refer to the filtered list, so callers must treat them as +/// ephemeral and refresh them whenever the filter changes. pub(crate) struct CommandPopup { + /// The active filter token derived from the composer, without the `/`. + /// + /// This preserves the original case from the composer so the UI can echo + /// exactly what the user typed even though matching is case-insensitive. command_filter: String, + /// Built-in slash commands paired with their display names. + /// + /// The list is cached in presentation order after feature gating so the + /// unfiltered popup is stable and filtering can reuse the same ordering. builtins: Vec<(&'static str, SlashCommand)>, + /// Custom prompts, filtered for name collisions and sorted by name. prompts: Vec, + /// Scroll and selection state for the filtered command list. + /// + /// The selection index always refers to the filtered list, so it must be + /// clamped whenever the filter token changes. state: ScrollState, } impl CommandPopup { + /// Builds a popup with the provided prompts and feature flags. + /// + /// Built-in commands are filtered for feature availability (including the + /// Windows degraded-sandbox state), and custom prompts that collide with + /// built-in names are dropped. Prompts are sorted by name so the unfiltered + /// list is stable for users. pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() @@ -45,6 +102,7 @@ impl CommandPopup { .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .collect(); + // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -57,6 +115,10 @@ impl CommandPopup { } } + /// Replaces the prompt list, filtering collisions with built-ins. + /// + /// This keeps the same sort and collision rules as [`CommandPopup::new`] + /// while preserving the current filter and selection state. pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { let exclude: HashSet = self .builtins @@ -68,14 +130,20 @@ impl CommandPopup { self.prompts = prompts; } + /// Returns the prompt at the provided index, if it still exists. pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { self.prompts.get(idx) } - /// Update the filter string based on the current composer text. The text - /// passed in is expected to start with a leading '/'. Everything after the - /// *first* '/" on the *first* line becomes the active filter that is used - /// to narrow down the list of available commands. + /// Updates the active filter based on the current composer text. + /// + /// The text is expected to start with a leading `/`. Everything after the + /// first `/` on the first line becomes the filter token that narrows the + /// available commands. The filter keeps the original case for display + /// stability even though command matching is case-insensitive today. + /// + /// The update flow is: parse the first token, update the filter, then clamp + /// and scroll the selection to keep it inside the filtered list. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); @@ -103,8 +171,10 @@ impl CommandPopup { .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Determine the preferred height of the popup for a given width. - /// Accounts for wrapped descriptions so that long tooltips don't overflow. + /// Determines the preferred popup height for the given width. + /// + /// This accounts for wrapped descriptions so long tooltips do not overflow + /// past the available viewport. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { use super::selection_popup_common::measure_rows_height; let rows = self.rows_from_matches(self.filtered()); @@ -112,9 +182,13 @@ impl CommandPopup { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } - /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Sorted by ascending - /// score, then by name for stability. + /// Computes the fuzzy-filtered matches and their highlight metadata. + /// + /// Each entry includes the matched item, the optional highlight indices + /// returned by [`fuzzy_match`], and its score. When a filter is present the + /// list is sorted by ascending score and then by name for deterministic + /// ordering. Custom prompts are matched against their `/prompts:name` + /// display strings so both `name` and `prompts:name` searches work. fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); @@ -123,6 +197,7 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { out.push((CommandItem::Builtin(*cmd), None, 0)); } + // Then prompts, already sorted by name. for idx in 0..self.prompts.len() { out.push((CommandItem::UserPrompt(idx), None, 0)); @@ -135,6 +210,7 @@ impl CommandPopup { out.push((CommandItem::Builtin(*cmd), Some(indices), score)); } } + // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. @@ -144,6 +220,7 @@ impl CommandPopup { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } + // When filtering, sort by ascending score and then by name for stability. out.sort_by(|a, b| { a.2.cmp(&b.2).then_with(|| { @@ -161,10 +238,15 @@ impl CommandPopup { out } + /// Returns only the matched command items, discarding highlight metadata. fn filtered_items(&self) -> Vec { self.filtered().into_iter().map(|(c, _, _)| c).collect() } + /// Builds display rows for the popup from match data. + /// + /// Highlight indices from [`fuzzy_match`] are shifted by one to account for + /// the leading `/` in the rendered command name. fn rows_from_matches( &self, matches: Vec<(CommandItem, Option>, i32)>, @@ -200,14 +282,14 @@ impl CommandPopup { .collect() } - /// Move the selection cursor one step up. + /// Moves the selection cursor one step up, wrapping at the top. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } - /// Move the selection cursor one step down. + /// Moves the selection cursor one step down, wrapping at the bottom. pub(crate) fn move_down(&mut self) { let matches_len = self.filtered_items().len(); self.state.move_down_wrap(matches_len); @@ -215,7 +297,7 @@ impl CommandPopup { .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Return currently selected command, if any. + /// Returns the currently selected command, if any. pub(crate) fn selected_item(&self) -> Option { let matches = self.filtered_items(); self.state @@ -225,6 +307,7 @@ impl CommandPopup { } impl WidgetRef for CommandPopup { + /// Renders the popup rows into the provided buffer. fn render_ref(&self, area: Rect, buf: &mut Buffer) { let rows = self.rows_from_matches(self.filtered()); render_rows( @@ -243,9 +326,11 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + /// Exercises prefix filtering by asserting `/init` remains among matches. #[test] fn filter_includes_init_when_typing_prefix() { let mut popup = CommandPopup::new(Vec::new(), false); + // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -263,6 +348,7 @@ mod tests { ); } + /// Verifies that exact matches are selected when the filter is fully typed. #[test] fn selecting_init_by_exact_match() { let mut popup = CommandPopup::new(Vec::new(), false); @@ -278,6 +364,7 @@ mod tests { } } + /// Verifies fuzzy ordering ranks `/model` first for the `/mo` filter. #[test] fn model_is_first_suggestion_for_mo() { let mut popup = CommandPopup::new(Vec::new(), false); @@ -292,6 +379,7 @@ mod tests { } } + /// Confirms unfiltered results include every custom prompt in name order. #[test] fn prompt_discovery_lists_custom_prompts() { let prompts = vec![ @@ -323,6 +411,7 @@ mod tests { assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); } + /// Asserts that prompts colliding with built-in names are dropped. #[test] fn prompt_name_collision_with_builtin_is_ignored() { // Create a prompt named like a builtin (e.g. "init"). @@ -347,6 +436,7 @@ mod tests { ); } + /// Asserts prompt rows use frontmatter descriptions when provided. #[test] fn prompt_description_uses_frontmatter_metadata() { let popup = CommandPopup::new( @@ -367,6 +457,7 @@ mod tests { ); } + /// Asserts prompt rows fall back to a generic description when missing. #[test] fn prompt_description_falls_back_when_missing() { let popup = CommandPopup::new( @@ -384,6 +475,7 @@ mod tests { assert_eq!(description, Some("send saved prompt")); } + /// Exercises fuzzy matching that allows non-prefix subsequences. #[test] fn fuzzy_filter_matches_subsequence_for_ac() { let mut popup = CommandPopup::new(Vec::new(), false); diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index e9f0ee697f..5641787b4a 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -1,3 +1,16 @@ +//! Collects free-form, multi-line custom prompt input in the bottom pane. +//! +//! The view owns a [`TextArea`] and its [`TextAreaState`], translating key and paste +//! events into text edits and rendering a compact popup with a title, optional +//! context line, and submission hint. It is intentionally lightweight: it only +//! accepts text, handles submission or cancellation, and reports completion back +//! to the bottom pane orchestrator. The input height is clamped to keep popups +//! compact, and Enter without modifiers is treated as a submit gesture rather +//! than a newline insertion. +//! +//! This module does not interpret the prompt contents or manage navigation +//! between bottom-pane modes; it focuses purely on input capture and rendering. + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -22,22 +35,40 @@ use super::textarea::TextArea; use super::textarea::TextAreaState; /// Callback invoked when the user submits a custom prompt. +/// +/// The callback receives the trimmed prompt text and is expected to trigger the +/// bottom-pane transition out of the custom prompt mode. pub(crate) type PromptSubmitted = Box; /// Minimal multi-line text input view to collect custom review instructions. +/// +/// The view owns the textarea widget and its mutable state, and it tracks a +/// completion flag used by the bottom pane to dismiss the popup. The submit +/// callback is invoked only when the trimmed text is non-empty, mirroring the +/// UI's expectation that empty submissions are ignored. pub(crate) struct CustomPromptView { + /// Title text rendered on the first line of the popup. title: String, + /// Placeholder hint rendered when the input is empty. placeholder: String, + /// Optional context line rendered between the title and input. context_label: Option, + /// Handler invoked once with the final, trimmed prompt text. on_submit: PromptSubmitted, - // UI state + /// Text input widget that owns the buffer and cursor logic. textarea: TextArea, + /// Stateful widget data (selection, cursor, scroll) for the textarea. textarea_state: RefCell, + /// Marker indicating the view has completed and should be dismissed. complete: bool, } impl CustomPromptView { + /// Creates a new prompt view configured with labels and a submit callback. + /// + /// The view starts empty and not-complete; the caller is responsible for + /// reacting to the submission callback or cancellation events. pub(crate) fn new( title: String, placeholder: String, @@ -57,6 +88,11 @@ impl CustomPromptView { } impl BottomPaneView for CustomPromptView { + /// Handles key input by editing the textarea or submitting/canceling. + /// + /// The handler treats Enter without modifiers as a submission attempt, + /// defers other Enter variants to the textarea for newline insertion, and + /// routes `Esc` to the same cancellation path as `Ctrl-C`. fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -87,15 +123,21 @@ impl BottomPaneView for CustomPromptView { } } + /// Marks the view as complete and reports a handled cancellation. fn on_ctrl_c(&mut self) -> CancellationEvent { self.complete = true; CancellationEvent::Handled } + /// Returns whether the view has finished (submitted or canceled). fn is_complete(&self) -> bool { self.complete } + /// Inserts pasted text into the textarea if it is non-empty. + /// + /// Empty paste payloads are treated as no-ops so the bottom pane can keep + /// other handlers in play. fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() { return false; @@ -106,11 +148,18 @@ impl BottomPaneView for CustomPromptView { } impl Renderable for CustomPromptView { + /// Computes the height needed to render title, optional context, input, and hints. fn desired_height(&self, width: u16) -> u16 { let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; 1u16 + extra_top + self.input_height(width) + 3u16 } + /// Renders the popup with title/context, textarea input, and hint line. + /// + /// Rendering proceeds in phases: title, optional context, guttered input, + /// placeholder overlay, and finally the hint line. The input area reserves a + /// one-line gutter to align with other bottom-pane popups and leaves a blank + /// spacer line before the hint. fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; @@ -213,6 +262,10 @@ impl Renderable for CustomPromptView { } } + /// Returns the cursor position inside the textarea, if it is visible. + /// + /// The cursor is offset by the title line, optional context line, and the + /// gutter column that prefixes the input area. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.height < 2 || area.width <= 2 { return None; @@ -235,6 +288,10 @@ impl Renderable for CustomPromptView { } impl CustomPromptView { + /// Computes the input area height for the given width, capped to keep popups compact. + /// + /// The returned height includes the single-row gutter and clamps the text + /// rows between 1 and 8, yielding a total height between 2 and 9. fn input_height(&self, width: u16) -> u16 { let usable_width = width.saturating_sub(2); let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); @@ -242,6 +299,7 @@ impl CustomPromptView { } } +/// Returns the colored gutter prefix used by popup lines. fn gutter() -> Span<'static> { "▌ ".cyan() } diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index dd0e84eb6d..7b85bec432 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -1,3 +1,13 @@ +//! Bottom-pane popup for toggling experimental feature flags. +//! +//! The view renders a selectable list of feature toggles, tracks selection and +//! scroll state, and emits a single update event when the user exits. It does +//! not persist configuration itself or validate feature availability; those +//! responsibilities live in the app event handler and config layer. The list is +//! intentionally compact, clamped to [`MAX_POPUP_ROWS`], and uses a guttered +//! layout consistent with other bottom-pane popups. Updates are emitted as +//! [`AppEvent::UpdateFeatureFlags`] once the user dismisses the popup. + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -29,23 +39,47 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; +/// Row model describing a togglable experimental feature. +/// +/// The view mutates the `enabled` flag in place as the user toggles items, then +/// ships the final state back to the app for persistence. pub(crate) struct BetaFeatureItem { + /// Feature flag identifier forwarded to the config update event. pub feature: Feature, + /// Human-friendly name shown in the list. pub name: String, + /// Longer description rendered under the name row. pub description: String, + /// Whether the feature is currently enabled for this session. + /// + /// This flag is updated directly when the user presses the toggle key. pub enabled: bool, } +/// Bottom-pane view that lets the user toggle experimental feature flags. +/// +/// The view owns the list of features, a scroll/selection state, and the app +/// event sender used to persist changes when exiting. It assumes ownership of +/// the selection cursor while active and marks itself complete once it has +/// emitted the update event. The caller remains responsible for swapping the +/// view into the bottom pane and for applying the emitted config updates. pub(crate) struct ExperimentalFeaturesView { + /// Feature rows that will be rendered and mutated in place. features: Vec, + /// Scroll and selection state for the list portion of the popup. state: ScrollState, + /// Completion flag checked by the bottom-pane controller. complete: bool, + /// Channel used to notify the app that feature flags changed. app_event_tx: AppEventSender, + /// Header renderer used for title and explanatory copy. header: Box, + /// Footer line that describes the available actions. footer_hint: Line<'static>, } impl ExperimentalFeaturesView { + /// Builds a new view with the provided feature list and event sender. pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { let mut header = ColumnRenderable::new(); header.push(Line::from("Experimental features".bold())); @@ -65,6 +99,7 @@ impl ExperimentalFeaturesView { view } + /// Initializes the selection cursor to the first visible item, if any. fn initialize_selection(&mut self) { if self.visible_len() == 0 { self.state.selected_idx = None; @@ -73,10 +108,12 @@ impl ExperimentalFeaturesView { } } + /// Returns the number of selectable rows currently visible. fn visible_len(&self) -> usize { self.features.len() } + /// Builds the display rows for the feature list, including selection markers. fn build_rows(&self) -> Vec { let mut rows = Vec::with_capacity(self.features.len()); let selected_idx = self.state.selected_idx; @@ -98,6 +135,7 @@ impl ExperimentalFeaturesView { rows } + /// Moves the selection up, wrapping and keeping the cursor visible. fn move_up(&mut self) { let len = self.visible_len(); if len == 0 { @@ -107,6 +145,7 @@ impl ExperimentalFeaturesView { self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + /// Moves the selection down, wrapping and keeping the cursor visible. fn move_down(&mut self) { let len = self.visible_len(); if len == 0 { @@ -116,6 +155,7 @@ impl ExperimentalFeaturesView { self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + /// Toggles the enabled flag for the currently selected feature. fn toggle_selected(&mut self) { let Some(selected_idx) = self.state.selected_idx else { return; @@ -126,12 +166,14 @@ impl ExperimentalFeaturesView { } } + /// Returns the width available to render rows after the gutter. fn rows_width(total_width: u16) -> u16 { total_width.saturating_sub(2) } } impl BottomPaneView for ExperimentalFeaturesView { + /// Handles navigation and toggling keys for the experimental feature list. fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -185,12 +227,16 @@ impl BottomPaneView for ExperimentalFeaturesView { } } + /// Reports whether the view has finished and can be dismissed. fn is_complete(&self) -> bool { self.complete } + /// Emits the feature updates and marks the view complete. + /// + /// The update is sent once on cancellation/exit so the app can persist the + /// final toggle state to configuration. fn on_ctrl_c(&mut self) -> CancellationEvent { - // Save the updates if !self.features.is_empty() { let updates = self .features @@ -207,6 +253,7 @@ impl BottomPaneView for ExperimentalFeaturesView { } impl Renderable for ExperimentalFeaturesView { + /// Renders the feature list popup, including header, list, and hint line. fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; @@ -265,6 +312,7 @@ impl Renderable for ExperimentalFeaturesView { self.footer_hint.clone().dim().render(hint_area, buf); } + /// Returns the desired height for the popup given the available width. fn desired_height(&self, width: u16) -> u16 { let rows = self.build_rows(); let rows_width = Self::rows_width(width); @@ -281,6 +329,7 @@ impl Renderable for ExperimentalFeaturesView { } } +/// Builds the footer hint line shown at the bottom of the popup. fn experimental_popup_hint_line() -> Line<'static> { Line::from(vec![ "Press ".into(), diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 8fef8e79a1..ec01cd7b9b 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,3 +1,24 @@ +//! Bottom-pane UI for collecting feedback notes and driving the feedback flow. +//! +//! This module renders the overlay used to capture an optional feedback note, +//! maps selected categories to backend classifications, and emits [`AppEvent`]s +//! that advance the flow (category → consent → note → upload). It owns only the +//! transient UI state (textarea content, cursor state, completion flag) and +//! forwards a caller-provided [`codex_feedback::CodexLogSnapshot`] when the user +//! submits. Selection-parameter helpers translate categories into popup content +//! and actions without storing any long-lived state. +//! +//! The UI layer is intentionally thin: it does not build snapshots, does not +//! retry uploads, and does not own the modal stack. Instead it publishes events +//! that drive the surrounding app state and relies on the caller to decide when +//! to open or close the overlay. +//! +//! Correctness relies on using the same [`FeedbackCategory`] for UI copy and +//! classification, and on honoring `include_logs` when deciding whether to +//! attach rollout artifacts. The set of categories that surface issue URLs must +//! stay in sync with the selection list so UI copy and backend labels remain +//! aligned, and the consent prompt must reflect the same log bundle that the +//! upload path will attach. use std::cell::RefCell; use std::path::PathBuf; @@ -27,25 +48,64 @@ use super::popup_consts::standard_popup_hint_line; use super::textarea::TextArea; use super::textarea::TextAreaState; +/// Base URL for prefilled bug reports when the flow suggests filing an issue. +/// +/// The final URL injects the thread identifier via a `steps` query parameter so +/// the issue template can reference the uploaded transcript. const BASE_BUG_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; -/// Minimal input overlay to collect an optional feedback note, then upload -/// both logs and rollout with classification + metadata. +/// Collects an optional feedback note and dispatches the feedback upload. +/// +/// The view owns the text input widget, cursor state, and completion flag, but +/// defers log collection and persistence to the surrounding application. +/// Completion is terminal: once the user submits or cancels, the view no longer +/// accepts input or emits further events. pub(crate) struct FeedbackNoteView { + /// Category that drives copy, classification, and issue-link behavior. + /// + /// This must stay aligned with the selection list so labels and upload + /// classifications match what the user saw. category: FeedbackCategory, + /// Snapshot containing the log metadata and thread identifier to upload. + /// + /// The snapshot is supplied by the caller so this view never recomputes + /// log contents; it forwards the metadata to `codex_feedback`. snapshot: codex_feedback::CodexLogSnapshot, + /// Optional rollout artifact path appended to uploads when logs are shared. + /// + /// The path is only sent when `include_logs` is true; otherwise it is + /// ignored even if present. rollout_path: Option, + /// Channel used to emit history updates or transitions after submission. + /// + /// Events emitted here are the only way the view communicates with the + /// application; it does not mutate shared state directly. app_event_tx: AppEventSender, + /// Whether the submission should include logs and rollout artifacts. + /// + /// This gates both the upload payload and the success copy shown to the + /// user after a successful submission. include_logs: bool, - // UI state + /// Input widget used to capture the optional user note. textarea: TextArea, + /// Mutable widget state tracked across renders and cursor queries. + /// + /// A `RefCell` is used because `render` and `cursor_pos` mutate state while + /// the view is held immutably by the renderer. textarea_state: RefCell, + /// Terminal flag that closes the overlay after submit or cancel. + /// + /// Once set, the view reports completion and ignores further input. complete: bool, } impl FeedbackNoteView { + /// Builds a feedback note overlay for the requested category and snapshot. + /// + /// The snapshot and rollout path are provided by the caller so the view can + /// render immediately without reaching into log collection. pub(crate) fn new( category: FeedbackCategory, snapshot: codex_feedback::CodexLogSnapshot, @@ -65,6 +125,15 @@ impl FeedbackNoteView { } } + /// Submits the feedback note and emits a history entry describing the result. + /// + /// The flow trims the current text, determines the category classification, + /// uploads via `codex_feedback`, and posts either a success or error cell to + /// the history stream before marking the overlay complete. The + /// `include_logs` flag controls whether rollout artifacts are attached and + /// which success copy is shown. A successful upload also includes the + /// thread identifier in the rendered history entry so users can reference + /// it in follow-up reports. fn submit(&mut self) { let note = self.textarea.text().trim().to_string(); let reason_opt = if note.is_empty() { @@ -136,6 +205,10 @@ impl FeedbackNoteView { } impl BottomPaneView for FeedbackNoteView { + /// Routes keyboard input to the textarea or triggers submit/cancel actions. + /// + /// The Enter key submits only when unmodified; modified Enter continues to + /// feed the textarea so users can insert newlines. fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -162,15 +235,18 @@ impl BottomPaneView for FeedbackNoteView { } } + /// Closes the overlay without submitting feedback. fn on_ctrl_c(&mut self) -> CancellationEvent { self.complete = true; CancellationEvent::Handled } + /// Reports whether the overlay has finished its submit/cancel flow. fn is_complete(&self) -> bool { self.complete } + /// Inserts pasted text into the input buffer. fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() { return false; @@ -181,10 +257,15 @@ impl BottomPaneView for FeedbackNoteView { } impl Renderable for FeedbackNoteView { + /// Returns the height needed for the title, input area, and footer hint. fn desired_height(&self, width: u16) -> u16 { 1u16 + self.input_height(width) + 3u16 } + /// Computes the terminal cursor position inside the input area, if visible. + /// + /// The cursor is only returned when the input gutter has room and the + /// textarea height is non-zero. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if area.height < 2 || area.width <= 2 { return None; @@ -193,7 +274,7 @@ impl Renderable for FeedbackNoteView { if text_area_height == 0 { return None; } - let top_line_count = 1u16; // title only + let top_line_count = 1u16; let textarea_rect = Rect { x: area.x.saturating_add(2), y: area.y.saturating_add(top_line_count).saturating_add(1), @@ -204,6 +285,11 @@ impl Renderable for FeedbackNoteView { self.textarea.cursor_pos_with_state(textarea_rect, state) } + /// Renders the title, text input, and footer hint for the feedback overlay. + /// + /// Rendering proceeds in three phases: draw the title gutter, paint the + /// input box with placeholder text when empty, and render the standardized + /// hint line at the bottom when there is space. fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; @@ -212,7 +298,6 @@ impl Renderable for FeedbackNoteView { let (title, placeholder) = feedback_title_and_placeholder(self.category); let input_height = self.input_height(area.width); - // Title line let title_area = Rect { x: area.x, y: area.y, @@ -222,7 +307,6 @@ impl Renderable for FeedbackNoteView { let title_spans: Vec> = vec![gutter(), title.bold()]; Paragraph::new(Line::from(title_spans)).render(title_area, buf); - // Input line let input_area = Rect { x: area.x, y: area.y.saturating_add(1), @@ -294,6 +378,10 @@ impl Renderable for FeedbackNoteView { } impl FeedbackNoteView { + /// Computes the height of the input region, clamping it to a small range. + /// + /// The height includes one row for the top padding inside the gutter and + /// keeps the textarea between one and eight lines tall. fn input_height(&self, width: u16) -> u16 { let usable_width = width.saturating_sub(2); let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); @@ -301,10 +389,17 @@ impl FeedbackNoteView { } } +/// Returns the left gutter span used by the feedback overlay. +/// +/// The cyan bar visually matches other bottom-pane prompts. fn gutter() -> Span<'static> { "▌ ".cyan() } +/// Returns the user-facing title and placeholder for a feedback category. +/// +/// Both strings are localized per category so copy and classification remain +/// aligned throughout the feedback flow. fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { match category { FeedbackCategory::BadResult => ( @@ -326,6 +421,10 @@ fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String } } +/// Maps a feedback category to the upload classification string. +/// +/// Keep this mapping in sync with the selection list and UI copy so analytics +/// and labels reflect what the user actually chose. fn feedback_classification(category: FeedbackCategory) -> &'static str { match category { FeedbackCategory::BadResult => "bad_result", @@ -335,6 +434,10 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str { } } +/// Returns the issue URL for categories that should file a bug report. +/// +/// The thread identifier is injected so the issue template can reference the +/// relevant transcript without additional user input. fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { match category { FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( @@ -344,7 +447,11 @@ fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option } } -// Build the selection popup params for feedback categories. +/// Builds the selection popup for choosing a feedback category. +/// +/// Each selection item emits an [`AppEvent::OpenFeedbackConsent`] action for +/// the chosen category so the caller can decide whether to show the upload +/// consent dialog. pub(crate) fn feedback_selection_params( app_event_tx: AppEventSender, ) -> super::SelectionViewParams { @@ -380,7 +487,9 @@ pub(crate) fn feedback_selection_params( } } -/// Build the selection popup params shown when feedback is disabled. +/// Builds the selection popup shown when feedback is disabled by configuration. +/// +/// The popup is read-only and only provides a close action. pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { super::SelectionViewParams { title: Some("Sending feedback is disabled".to_string()), @@ -395,6 +504,10 @@ pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { } } +/// Builds a selection item that opens the feedback consent prompt. +/// +/// The returned item owns an action closure that captures the category so the +/// selection list stays data-driven. fn make_feedback_item( app_event_tx: AppEventSender, name: &str, @@ -413,7 +526,10 @@ fn make_feedback_item( } } -/// Build the upload consent popup params for a given feedback category. +/// Builds the upload consent popup for a given feedback category. +/// +/// The header lists the standard log filename plus any rollout artifact name +/// provided by `rollout_path`. pub(crate) fn feedback_upload_consent_params( app_event_tx: AppEventSender, category: FeedbackCategory, @@ -442,7 +558,6 @@ pub(crate) fn feedback_upload_consent_params( } }); - // Build header listing files that would be sent if user consents. let mut header_lines: Vec> = vec![ Line::from("Upload logs?".bold()).into(), Line::from("").into(), @@ -483,12 +598,14 @@ pub(crate) fn feedback_upload_consent_params( } } +/// Snapshot coverage for feedback view rendering and issue URL behavior. #[cfg(test)] mod tests { use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; + /// Renders the feedback overlay to a trimmed string for snapshot testing. fn render(view: &FeedbackNoteView, width: u16) -> String { let height = view.desired_height(width); let area = Rect::new(0, 0, width, height); @@ -519,6 +636,7 @@ mod tests { lines.join("\n") } + /// Builds a feedback note view with an empty snapshot for rendering tests. fn make_view(category: FeedbackCategory) -> FeedbackNoteView { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -526,6 +644,7 @@ mod tests { FeedbackNoteView::new(category, snapshot, None, tx, true) } + /// Ensures the bad-result overlay renders a stable snapshot. #[test] fn feedback_view_bad_result() { let view = make_view(FeedbackCategory::BadResult); @@ -533,6 +652,7 @@ mod tests { insta::assert_snapshot!("feedback_view_bad_result", rendered); } + /// Ensures the good-result overlay renders a stable snapshot. #[test] fn feedback_view_good_result() { let view = make_view(FeedbackCategory::GoodResult); @@ -540,6 +660,7 @@ mod tests { insta::assert_snapshot!("feedback_view_good_result", rendered); } + /// Ensures the bug overlay renders a stable snapshot. #[test] fn feedback_view_bug() { let view = make_view(FeedbackCategory::Bug); @@ -547,6 +668,7 @@ mod tests { insta::assert_snapshot!("feedback_view_bug", rendered); } + /// Ensures the other overlay renders a stable snapshot. #[test] fn feedback_view_other() { let view = make_view(FeedbackCategory::Other); @@ -554,6 +676,7 @@ mod tests { insta::assert_snapshot!("feedback_view_other", rendered); } + /// Verifies which categories produce issue URLs. #[test] fn issue_url_available_for_bug_bad_result_and_other() { let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 48de1cff54..be8773aff8 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -1,3 +1,22 @@ +//! Maintains render-ready state for the file-search popup in the bottom pane. +//! +//! This module owns the transient UI state that sits between async file searches +//! and bottom-pane rendering. It tracks the last displayed query versus the most +//! recently typed query, caches the last result set, and carries shared +//! scroll/selection state so the list remains stable while new results arrive. +//! +//! The popup does not execute searches or decide when it is shown. Callers drive the +//! state machine by calling [`FileSearchPopup::set_query`], +//! [`FileSearchPopup::set_matches`], or [`FileSearchPopup::set_empty_prompt`]. The +//! correctness invariant is that displayed matches always correspond to +//! `display_query`; results for older queries are ignored so the UI cannot regress, +//! and scroll state is preserved across updates to avoid jumpy selection. +//! +//! An explicit empty-query state renders a hint instead of issuing searches, and +//! the `waiting` flag switches between "loading" and "no matches" messaging when +//! the list is empty. Selection state remains authoritative even while results are +//! inflight, so navigation continues to feel stable. + use codex_file_search::FileMatch; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -11,22 +30,52 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; -/// Visual state for the file-search popup. +/// Holds the render and selection state for the file-search popup. +/// +/// This struct is a small UI state machine: it tracks the query the user most +/// recently typed, the query that produced the currently visible matches, and +/// whether a search is still in flight. Callers update the query, provide +/// matches as they arrive, and the struct maintains a stable selection even as +/// results change. The popup renders by converting cached matches into display +/// rows and then delegating to the shared selection popup renderer. +/// +/// The primary invariant is that `matches` always correspond to `display_query`. +/// When results arrive out of order, stale payloads are dropped so the UI never +/// regresses to an older search. pub(crate) struct FileSearchPopup { - /// Query corresponding to the `matches` currently shown. + /// Query that produced the currently displayed `matches`. + /// + /// This is updated only when new results arrive so the UI never shows stale + /// results for an older search. display_query: String, - /// Latest query typed by the user. May differ from `display_query` when - /// a search is still in-flight. + /// Most recently typed query, even if the search has not completed yet. + /// + /// The field may differ from `display_query` while an async search is in + /// flight; it is used to drop stale result sets when they arrive out of + /// order. pending_query: String, - /// When `true` we are still waiting for results for `pending_query`. + /// When `true`, the popup is waiting on results for `pending_query`. + /// + /// The renderer uses this to decide whether to show a loading hint or a + /// "no matches" message when the list is empty. waiting: bool, - /// Cached matches; paths relative to the search dir. + /// Cached matches for `display_query`, with paths relative to the search root. + /// + /// The order is whatever the search backend provided, so renderers preserve it + /// when mapping into display rows. matches: Vec, - /// Shared selection/scroll state. + /// Shared selection and scroll position for the result list. + /// + /// This is the single source of truth for selection, so all movement helpers + /// and renderers must keep it in sync with `matches`. state: ScrollState, } impl FileSearchPopup { + /// Create an empty popup state in the initial waiting mode. + /// + /// The initial waiting flag ensures the first render shows a loading hint + /// while the async search kicks off. pub(crate) fn new() -> Self { Self { display_query: String::new(), @@ -37,7 +86,12 @@ impl FileSearchPopup { } } - /// Update the query and reset state to *waiting*. + /// Update the query and transition the popup into the waiting state. + /// + /// The transition keeps existing matches when the new query is an extension + /// of `display_query`, so the list stays visible while newer results are + /// computed. Otherwise the list and selection state are cleared to avoid + /// mixing unrelated result sets. pub(crate) fn set_query(&mut self, query: &str) { if query == self.pending_query { return; @@ -49,7 +103,7 @@ impl FileSearchPopup { self.pending_query.clear(); self.pending_query.push_str(query); - self.waiting = true; // waiting for new results + self.waiting = true; if !keep_existing { self.matches.clear(); @@ -57,22 +111,28 @@ impl FileSearchPopup { } } - /// Put the popup into an "idle" state used for an empty query (just "@"). - /// Shows a hint instead of matches until the user types more characters. + /// Put the popup into an idle state used for an empty query (just "@"). + /// + /// This shows a hint instead of results until the user types more characters + /// and we can issue a real search. pub(crate) fn set_empty_prompt(&mut self) { self.display_query.clear(); self.pending_query.clear(); self.waiting = false; self.matches.clear(); + // Reset selection/scroll state when showing the empty prompt. self.state.reset(); } - /// Replace matches when a `FileSearchResult` arrives. - /// Replace matches. Only applied when `query` matches `pending_query`. + /// Replace matches when results arrive for the active `pending_query`. + /// + /// Stale results are ignored so that out-of-order responses cannot regress + /// the visible list. Selection is clamped to the new list length and then + /// made visible within the popup's row budget. pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { if query != self.pending_query { - return; // stale + return; // Stale response. } self.display_query = query.to_string(); @@ -83,20 +143,28 @@ impl FileSearchPopup { self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } - /// Move selection cursor up. + /// Move the selection cursor up, wrapping within the current result list. + /// + /// The selection is re-centered within the visible rows after moving. pub(crate) fn move_up(&mut self) { let len = self.matches.len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } - /// Move selection cursor down. + /// Move the selection cursor down, wrapping within the current result list. + /// + /// The selection is re-centered within the visible rows after moving. pub(crate) fn move_down(&mut self) { let len = self.matches.len(); self.state.move_down_wrap(len); self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); } + /// Return the selected match path, if any selection exists. + /// + /// The returned path matches the entries stored in [`FileMatch::path`], + /// which are already relative to the search root. pub(crate) fn selected_match(&self) -> Option<&str> { self.state .selected_idx @@ -104,11 +172,15 @@ impl FileSearchPopup { .map(|file_match| file_match.path.as_str()) } + /// Report the popup height needed to render the current state. + /// + /// The height is clamped so the popup remains visible even when there are + /// no results yet, and is capped by [`MAX_POPUP_ROWS`] to avoid overflow. pub(crate) fn calculate_required_height(&self) -> u16 { // Row count depends on whether we already have matches. If no matches // yet (e.g. initial search or query with no results) reserve a single // row so the popup is still visible. When matches are present we show - // up to MAX_RESULTS regardless of the waiting flag so the list + // up to MAX_POPUP_ROWS regardless of the waiting flag so the list // remains stable while a newer search is in-flight. self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 @@ -116,6 +188,11 @@ impl FileSearchPopup { } impl WidgetRef for &FileSearchPopup { + /// Render the popup rows into the provided buffer. + /// + /// This converts search matches into `GenericDisplayRow` values, translating + /// match indices into `usize` at the UI boundary, then delegates to the + /// shared row renderer with the appropriate empty-state message. fn render_ref(&self, area: Rect, buf: &mut Buffer) { // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. let rows_all: Vec = if self.matches.is_empty() { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 42c0392a61..154840b8b4 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,13 +1,18 @@ //! The bottom-pane footer renders transient hints and context indicators. //! -//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state. -//! It intentionally does not decide *which* footer content should be shown; that is owned by the -//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like +//! The footer is pure rendering: it formats [`FooterProps`] into `Line`s without mutating any +//! state. It intentionally does not decide *which* footer content should be shown; that is owned +//! by the `ChatComposer` (which selects a [`FooterMode`]) and by higher-level state machines like //! `ChatWidget` (which decides when quit/interrupt is allowed). //! //! Some footer content is time-based rather than event-based, such as the "press again to quit" //! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is //! otherwise idle. +//! +//! Context indicators are rendered from caller-provided percentages or token counts. When both are +//! present, the percentage takes precedence; when neither is present the footer assumes a full +//! context window and renders a default hint. The footer also expands into a multi-line shortcut +//! overlay that conditionally includes entries based on environment and compositor state. #[cfg(target_os = "linux")] use crate::clipboard_paste::is_probably_wsl; use crate::key_hint; @@ -29,19 +34,27 @@ use ratatui::widgets::Widget; /// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`, /// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as /// authoritative and does not attempt to infer missing state (for example, it does not query -/// whether a task is running). +/// whether a task is running). Context and shortcut hints are selected entirely by `mode`, while +/// the remaining flags refine how those hints are worded or whether secondary hints appear. #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { + /// Which content variant to render in the footer. pub(crate) mode: FooterMode, + /// Whether `Esc` is currently primed for a second press backtrack. pub(crate) esc_backtrack_hint: bool, + /// Whether the newline hint should prefer Shift+Enter over Ctrl+J. pub(crate) use_shift_enter_hint: bool, + /// Whether a model task is currently running in the background. pub(crate) is_task_running: bool, + /// Whether steering/queueing is enabled for the active task. pub(crate) steer_enabled: bool, /// Which key the user must press again to quit. /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. pub(crate) quit_shortcut_key: KeyBinding, + /// Percent of context remaining for the active model, if known. pub(crate) context_window_percent: Option, + /// Total tokens used so far in the context window, if known. pub(crate) context_window_used_tokens: Option, } @@ -53,12 +66,20 @@ pub(crate) struct FooterProps { pub(crate) enum FooterMode { /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). QuitShortcutReminder, + /// One-line hint showing the shortcut summary with context metadata. ShortcutSummary, + /// Multi-line overlay listing the most common shortcuts. ShortcutOverlay, + /// Inline `Esc` hint for editing the previous message. EscHint, + /// Context indicator only (no global shortcuts). ContextOnly, } +/// Toggles the shortcut display between summary and overlay modes. +/// +/// When the quit shortcut reminder is active, the footer stays in that mode if +/// the caller still wants the Ctrl+C hint shown. pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) { return current; @@ -72,6 +93,10 @@ pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> Fo } } +/// Returns the `Esc` hint mode unless a task is running. +/// +/// Active tasks keep the current footer mode so the running-state hints remain +/// visible instead of being replaced by the `Esc` reminder. pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { if is_task_running { current @@ -80,6 +105,10 @@ pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> Foote } } +/// Resets transient footer modes after user activity. +/// +/// Activity (typing, streaming, etc.) collapses temporary overlays back to the +/// default shortcut summary, while leaving stable modes unchanged. pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { match current { FooterMode::EscHint @@ -90,10 +119,15 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } } +/// Computes the number of rows required to render the footer for the given props. pub(crate) fn footer_height(props: FooterProps) -> u16 { footer_lines(props).len() as u16 } +/// Renders the footer into the provided area using the supplied props. +/// +/// The renderer prepends a left indent so footer content aligns with the +/// bottom-pane text area. pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { Paragraph::new(prefix_lines( footer_lines(props), @@ -103,11 +137,12 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { .render(area, buf); } +/// Builds the footer lines for the current mode and context metadata. +/// +/// The context indicator is appended to the primary hint for single-line modes +/// and omitted for the multi-line overlay. Context-only mode may additionally +/// include the queue-message hint when a task is running and steering is enabled. fn footer_lines(props: FooterProps) -> Vec> { - // Show the context indicator on the left, appended after the primary hint - // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when - // the shortcut hint is hidden). Hide it only for the multi-line - // ShortcutOverlay. match props.mode { FooterMode::QuitShortcutReminder => { vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] @@ -153,17 +188,23 @@ fn footer_lines(props: FooterProps) -> Vec> { } } +/// Aggregated flags that select which shortcut hint variants are visible. #[derive(Clone, Copy, Debug)] struct ShortcutsState { + /// Whether the newline hint should favor Shift+Enter over Ctrl+J. use_shift_enter_hint: bool, + /// Whether the backtrack hint uses the single-press wording. esc_backtrack_hint: bool, + /// Whether WSL-specific shortcut hints should be shown. is_wsl: bool, } +/// Renders the "press again to quit" reminder line. fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { Line::from(vec![key.into(), " again to quit".into()]).dim() } +/// Renders the `Esc` hint line, adapting to the backtrack priming state. fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { let esc = key_hint::plain(KeyCode::Esc); if esc_backtrack_hint { @@ -179,6 +220,10 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { } } +/// Builds the multi-line shortcut overlay, ordered by the shortcut descriptor list. +/// +/// Entries are populated based on the current display state, then laid out into +/// two columns for compact rendering. fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); @@ -225,6 +270,10 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { build_columns(ordered) } +/// Arranges shortcut lines into padded columns with a fixed gap between columns. +/// +/// The width computation is based on the rendered line width so aligned columns +/// keep their key labels visually consistent. fn build_columns(entries: Vec>) -> Vec> { if entries.is_empty() { return Vec::new(); @@ -272,6 +321,10 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } +/// Formats the context window indicator for the footer. +/// +/// Percent-based hints take precedence when available; token counts are used +/// otherwise, and the default assumes a full context window remaining. fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { if let Some(percent) = percent { let percent = percent.clamp(0, 100); @@ -286,41 +339,62 @@ fn context_window_line(percent: Option, used_tokens: Option) -> Line<' Line::from(vec![Span::from("100% context left").dim()]) } +/// Identifies each shortcut entry in the overlay list. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { + /// Shows the slash-command shortcut. Commands, + /// Shows the shell-command shortcut. ShellCommands, + /// Shows the insert-newline shortcut. InsertNewline, + /// Shows the queue-message shortcut. QueueMessageTab, + /// Shows the file-paths shortcut. FilePaths, + /// Shows the paste-image shortcut. PasteImage, + /// Shows the external-editor shortcut. ExternalEditor, + /// Shows the edit-previous shortcut. EditPrevious, + /// Shows the quit shortcut. Quit, + /// Shows the transcript shortcut. ShowTranscript, } +/// A key binding and the condition required for it to be shown. #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ShortcutBinding { + /// The formatted key hint to display. key: KeyBinding, + /// The visibility rule for this binding. condition: DisplayCondition, } impl ShortcutBinding { + /// Returns true when the binding should be shown for the given state. fn matches(&self, state: ShortcutsState) -> bool { self.condition.matches(state) } } +/// Governs whether a shortcut binding is visible in the overlay. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DisplayCondition { + /// Always show the binding. Always, + /// Show the binding only when Shift+Enter should be hinted. WhenShiftEnterHint, + /// Show the binding when Shift+Enter should not be hinted. WhenNotShiftEnterHint, + /// Show the binding only for WSL environments. WhenUnderWSL, } impl DisplayCondition { + /// Returns true if the current state satisfies this condition. fn matches(self, state: ShortcutsState) -> bool { match self { DisplayCondition::Always => true, @@ -331,18 +405,28 @@ impl DisplayCondition { } } +/// Describes a shortcut entry, including its preferred bindings and label. struct ShortcutDescriptor { + /// Identifier used to place the entry in the overlay list. id: ShortcutId, + /// Candidate bindings, evaluated in order. bindings: &'static [ShortcutBinding], + /// Prefix text rendered before the key hint. prefix: &'static str, + /// Label text rendered after the key hint. label: &'static str, } impl ShortcutDescriptor { + /// Selects the first binding that matches the display state. fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { self.bindings.iter().find(|binding| binding.matches(state)) } + /// Builds the formatted line shown in the shortcut overlay. + /// + /// The edit-previous entry has a custom label that references the `Esc` + /// backtrack priming state. fn overlay_entry(&self, state: ShortcutsState) -> Option> { let binding = self.binding_for(state)?; let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); @@ -364,6 +448,7 @@ impl ShortcutDescriptor { } } +/// Ordered list of shortcut entries rendered in the overlay. const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { id: ShortcutId::Commands, @@ -418,6 +503,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ShortcutDescriptor { id: ShortcutId::PasteImage, + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain // Ctrl+V); otherwise fall back to Ctrl+V. bindings: &[ @@ -478,6 +564,7 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; + /// Renders the footer into a fixed-size buffer and snapshots the result. fn snapshot_footer(name: &str, props: FooterProps) { let height = footer_height(props).max(1); let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); @@ -490,6 +577,7 @@ mod tests { assert_snapshot!(name, terminal.backend()); } + /// Verifies the footer variants render stable, expected output. #[test] fn footer_snapshots() { snapshot_footer( diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 40787a9c25..3c0b1451b9 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -1,3 +1,22 @@ +//! Scrollable selection list overlay with optional search and footer content. +//! +//! This module defines the data structures and renderer for a generic +//! bottom-pane selection popup. It owns transient selection state, applies an +//! optional search filter, and dispatches the selected item's actions via +//! [`AppEventSender`]. The view is responsible for visual layout (header, +//! search row, list rows, footer) and for keeping the highlighted row visible +//! as the user navigates. +//! +//! It does not interpret the meaning of items or persist selection; callers +//! provide [`SelectionItem`]s and handle the emitted side effects in their +//! action closures. Correctness relies on keeping `filtered_indices` aligned +//! with the `items` vector and on skipping disabled rows when navigating. +//! +//! Selection state is tracked in terms of the filtered (visible) indices, so +//! callers must re-run filtering whenever the query or item set changes. The +//! view treats number key shortcuts as direct selection only when search is +//! disabled, so digits remain available for search input. + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -31,36 +50,69 @@ use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; use unicode_width::UnicodeWidthStr; -/// One selectable item in the generic selection list. +/// Action invoked when a selection item is accepted. +/// +/// Actions receive the shared [`AppEventSender`] so they can enqueue app-level +/// effects without owning UI state. pub(crate) type SelectionAction = Box; +/// One selectable item rendered by [`ListSelectionView`]. +/// +/// The item owns its display text and optional metadata (shortcuts, current +/// markers, search text). Action closures run when the item is accepted, and +/// disabled rows remain visible but cannot be selected. #[derive(Default)] pub(crate) struct SelectionItem { + /// Primary label shown in the list. pub name: String, + /// Optional key hint displayed alongside the label. pub display_shortcut: Option, + /// Secondary description shown when not selected. pub description: Option, + /// Alternate description shown when this row is selected. pub selected_description: Option, + /// Marks the row as representing the current choice. pub is_current: bool, + /// Marks the row as the default choice when no current selection exists. pub is_default: bool, + /// Actions to run when the item is accepted. pub actions: Vec, + /// Whether accepting this item should close the popup. pub dismiss_on_select: bool, + /// Search text used when filtering is enabled. pub search_value: Option, + /// Disabled reason shown in the UI; disables selection when present. pub disabled_reason: Option, } +/// Configuration used to construct a [`ListSelectionView`]. +/// +/// The header content is rendered above any title/subtitle lines, and the +/// optional `initial_selected_idx` is expressed in terms of the full `items` +/// vector, not the filtered list. pub(crate) struct SelectionViewParams { + /// Optional title line rendered in the header. pub title: Option, + /// Optional subtitle line rendered beneath the title. pub subtitle: Option, + /// Optional footer note rendered above the hint line. pub footer_note: Option>, + /// Optional footer hint rendered at the bottom of the popup. pub footer_hint: Option>, + /// Selection items displayed in the list. pub items: Vec, + /// Whether the list supports free-text filtering. pub is_searchable: bool, + /// Placeholder shown when the search query is empty. pub search_placeholder: Option, + /// Optional extra header content rendered above the title/subtitle. pub header: Box, + /// Optional starting selection index in the full `items` list. pub initial_selected_idx: Option, } impl Default for SelectionViewParams { + /// Creates empty parameters with no title, items, or search behavior. fn default() -> Self { Self { title: None, @@ -76,23 +128,44 @@ impl Default for SelectionViewParams { } } +/// Renders and manages a selectable list with optional search filtering. +/// +/// The view stores item state, filtered indices, and a scroll position. It +/// dispatches the selected item's actions and tracks the last accepted index +/// so callers can query which item was chosen after dismissal. Selection is +/// tracked in terms of visible indices, so any change to the filtered list +/// must be followed by `apply_filter` to keep indices coherent. pub(crate) struct ListSelectionView { + /// Optional footer note rendered above the hint. footer_note: Option>, + /// Optional footer hint rendered at the bottom. footer_hint: Option>, + /// All available selection items. items: Vec, + /// Scroll state for the currently visible list rows. state: ScrollState, + /// Whether the view has completed and should be dismissed. complete: bool, + /// Event channel used by item actions. app_event_tx: AppEventSender, + /// Whether the list supports free-text search filtering. is_searchable: bool, + /// Current search query text. search_query: String, + /// Placeholder text shown when the search query is empty. search_placeholder: Option, + /// Indices into `items` that are currently visible. filtered_indices: Vec, + /// The last accepted index in the `items` vector. last_selected_actual_idx: Option, + /// Header renderer for the popup above the list. header: Box, + /// Initial selection index supplied by the caller. initial_selected_idx: Option, } impl ListSelectionView { + /// Builds a list view from configuration parameters and an event sender. pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { let mut header = params.header; if params.title.is_some() || params.subtitle.is_some() { @@ -127,14 +200,21 @@ impl ListSelectionView { s } + /// Returns the number of currently visible (filtered) rows. fn visible_len(&self) -> usize { self.filtered_indices.len() } + /// Returns the maximum rows to show given the popup row limit. fn max_visible_rows(len: usize) -> usize { MAX_POPUP_ROWS.min(len.max(1)) } + /// Recomputes the filtered indices and clamps the selection accordingly. + /// + /// Selection attempts to preserve the previously selected actual index when + /// possible, then falls back to any `is_current` item, then to the caller's + /// initial selection, and finally to the first visible row. fn apply_filter(&mut self) { let previously_selected = self .state @@ -185,6 +265,10 @@ impl ListSelectionView { self.state.ensure_visible(len, visible); } + /// Builds display rows for the current filtered view. + /// + /// This applies selection prefixes, numbering (when search is disabled), and + /// wraps the selected description in preference to the standard description. fn build_rows(&self) -> Vec { self.filtered_indices .iter() @@ -230,6 +314,7 @@ impl ListSelectionView { .collect() } + /// Moves the selection upward and skips any disabled rows. fn move_up(&mut self) { let len = self.visible_len(); self.state.move_up_wrap(len); @@ -238,6 +323,7 @@ impl ListSelectionView { self.skip_disabled_up(); } + /// Moves the selection downward and skips any disabled rows. fn move_down(&mut self) { let len = self.visible_len(); self.state.move_down_wrap(len); @@ -246,6 +332,10 @@ impl ListSelectionView { self.skip_disabled_down(); } + /// Accepts the current selection, running actions and updating completion. + /// + /// If no valid enabled row is selected, the popup completes without invoking + /// any actions. fn accept(&mut self) { if let Some(idx) = self.state.selected_idx && let Some(actual_idx) = self.filtered_indices.get(idx) @@ -265,19 +355,23 @@ impl ListSelectionView { } #[cfg(test)] + /// Sets the search query and updates the filtered list for testing. pub(crate) fn set_search_query(&mut self, query: String) { self.search_query = query; self.apply_filter(); } + /// Returns and clears the last accepted actual index. pub(crate) fn take_last_selected_index(&mut self) -> Option { self.last_selected_actual_idx.take() } + /// Returns the width available for list rows within the popup. fn rows_width(total_width: u16) -> u16 { total_width.saturating_sub(2) } + /// Advances the selection until it lands on a non-disabled row. fn skip_disabled_down(&mut self) { let len = self.visible_len(); for _ in 0..len { @@ -295,6 +389,7 @@ impl ListSelectionView { } } + /// Moves the selection upward until a non-disabled row is selected. fn skip_disabled_up(&mut self) { let len = self.visible_len(); for _ in 0..len { @@ -314,6 +409,7 @@ impl ListSelectionView { } impl BottomPaneView for ListSelectionView { + /// Handles navigation, selection, and search input for the list. fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { // Some terminals (or configurations) send Control key chords as @@ -410,10 +506,12 @@ impl BottomPaneView for ListSelectionView { } } + /// Reports whether the list popup has completed. fn is_complete(&self) -> bool { self.complete } + /// Cancels the popup without choosing an item. fn on_ctrl_c(&mut self) -> CancellationEvent { self.complete = true; CancellationEvent::Handled @@ -421,6 +519,10 @@ impl BottomPaneView for ListSelectionView { } impl Renderable for ListSelectionView { + /// Computes the total height needed to render the header, list, and footer. + /// + /// This mirrors the same wrapping logic used at render time so callers can + /// size the popup without recomputing layout rules. fn desired_height(&self, width: u16) -> u16 { // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. // Build the same display rows used by the renderer so wrapping math matches. @@ -450,6 +552,10 @@ impl Renderable for ListSelectionView { height } + /// Renders the header, optional search line, list rows, and footer content. + /// + /// The header may be elided when it exceeds the available height, in which + /// case an instruction line hints at using the full-screen view. fn render(&self, area: Rect, buf: &mut Buffer) { if area.height == 0 || area.width == 0 { return; @@ -572,6 +678,7 @@ impl Renderable for ListSelectionView { } } +/// Snapshot coverage for list selection layout and filtering behavior. #[cfg(test)] mod tests { use super::*; @@ -581,6 +688,7 @@ mod tests { use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; + /// Builds a minimal selection view with optional subtitle content. fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -612,10 +720,12 @@ mod tests { ) } + /// Renders the view using the default snapshot width. fn render_lines(view: &ListSelectionView) -> String { render_lines_with_width(view, 48) } + /// Renders the view at a custom width for snapshot assertions. fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { let height = view.desired_height(width); let area = Rect::new(0, 0, width, height); @@ -639,6 +749,7 @@ mod tests { lines.join("\n") } + /// Ensures spacing when there is no subtitle. #[test] fn renders_blank_line_between_title_and_items_without_subtitle() { let view = make_selection_view(None); @@ -648,12 +759,14 @@ mod tests { ); } + /// Ensures spacing when both title and subtitle are present. #[test] fn renders_blank_line_between_subtitle_and_items() { let view = make_selection_view(Some("Switch between Codex approval presets")); assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + /// Verifies footer notes wrap when the view is narrow. #[test] fn snapshot_footer_note_wraps() { let (tx_raw, _rx) = unbounded_channel::(); @@ -686,6 +799,7 @@ mod tests { ); } + /// Renders the search row when the list is searchable. #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); @@ -717,6 +831,7 @@ mod tests { ); } + /// Ensures long entries wrap under the numbered prefix without truncation. #[test] fn wraps_long_option_without_overflowing_columns() { let (tx_raw, _rx) = unbounded_channel::(); @@ -758,6 +873,7 @@ mod tests { ); } + /// Checks that changing width does not drop list rows. #[test] fn width_changes_do_not_hide_rows() { let (tx_raw, _rx) = unbounded_channel::(); @@ -812,6 +928,7 @@ mod tests { ); } + /// Ensures narrow widths still render all rows. #[test] fn narrow_width_keeps_all_rows_visible() { let (tx_raw, _rx) = unbounded_channel::(); @@ -840,6 +957,7 @@ mod tests { ); } + /// Snapshot of the model picker layout at width 80. #[test] fn snapshot_model_picker_width_80() { let (tx_raw, _rx) = unbounded_channel::(); @@ -887,6 +1005,7 @@ mod tests { ); } + /// Snapshot of narrow layout preserving all rows. #[test] fn snapshot_narrow_width_preserves_third_option() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f270145a04..1902461de0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,18 +1,26 @@ //! The bottom pane is the interactive footer of the chat UI. //! -//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient -//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused -//! interactions like selection lists. +//! The pane owns the [`ChatComposer`] (editable prompt input), a stack of transient +//! [`BottomPaneView`]s (popups/modals), and the footer-only widgets that live above the composer +//! (status indicator, unified exec summary, queued messages). Modal views temporarily replace the +//! composer as the active input surface, but the composer remains alive so draft input returns +//! unchanged when the modal is dismissed. //! -//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs +//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs. //! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent //! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active //! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may //! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit //! shortcut. //! +//! Modal lifecycles also affect status rendering. When a modal is active, status timers are paused +//! to avoid animating under overlays, and they are resumed when the modal completes. The pane never +//! owns task lifecycle; it only mirrors task state passed in by the parent and forwards interrupts +//! when the UI affordance is visible. +//! //! Some UI is time-based rather than input-based, such as the transient "press again to quit" -//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. +//! hint and paste-burst flushing. The pane schedules redraws so those hints can expire or flush +//! even when the UI is otherwise idle. use std::path::PathBuf; use crate::app_event_sender::AppEventSender; @@ -85,7 +93,9 @@ pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false; /// not handled locally. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { + /// The key was handled by the active surface. Handled, + /// The key was not handled and should be interpreted by the caller. NotHandled, } @@ -104,44 +114,105 @@ pub(crate) use list_selection_view::SelectionItem; /// This is the owning container for the prompt input (`ChatComposer`) and the view stack /// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving /// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`. +/// +/// The pane also owns footer-level status UI (interrupt hints, unified exec summaries, queued +/// message banners) so these elements remain consistent regardless of which view is active. pub(crate) struct BottomPane { - /// Composer is retained even when a BottomPaneView is displayed so the - /// input state is retained when the view is closed. + /// Composer retained while modal views are active so draft input is restored on dismissal. composer: ChatComposer, /// Stack of views displayed instead of the composer (e.g. popups/modals). + /// + /// The top-most view is the active receiver of input and rendering. view_stack: Vec>, + /// Sender for app-level events emitted by bottom-pane widgets. app_event_tx: AppEventSender, + + /// Frame scheduler used for time-based redraws and delayed hint expiration. frame_requester: FrameRequester, + /// True when the bottom pane should accept user input. + /// + /// This is forwarded to the composer and influences which hints are shown. has_input_focus: bool, + + /// True while a task is actively running in the session. + /// + /// This is owned by the parent widget and controls whether status UI is shown. is_task_running: bool, + + /// Whether to show the Esc backtrack hint in the footer. + /// + /// This is kept in sync with the composer so footer hints remain consistent. esc_backtrack_hint: bool, + + /// Whether animation effects are enabled for this session. + /// + /// This is passed to animated widgets such as the status indicator. animations_enabled: bool, /// Inline status indicator shown above the composer while a task is running. + /// + /// This is created lazily and updated as the task transitions through phases. Modal overlays + /// pause its timer so it does not animate behind popups. status: Option, + /// Unified exec session summary shown above the composer. + /// + /// This aggregates related exec commands so the footer stays compact while tasks run. unified_exec_footer: UnifiedExecFooter, + /// Queued user messages to show above the composer while a turn is running. + /// + /// These are rendered as a temporary banner so the user knows what will be submitted next. queued_user_messages: QueuedUserMessages, + + /// Current context window usage as a percentage, if known. + /// + /// This is sourced from backend usage snapshots and displayed in the footer. context_window_percent: Option, + + /// Current context window usage in tokens, if known. + /// + /// This is paired with the percentage to show absolute usage. context_window_used_tokens: Option, } +/// Construction parameters for [`BottomPane`]. +/// +/// This bundles input configuration, placeholder text, and shared handles needed to wire the +/// composer and view stack consistently. pub(crate) struct BottomPaneParams { + /// Sender for app-level events emitted by bottom-pane widgets. pub(crate) app_event_tx: AppEventSender, + + /// Frame scheduler used for time-based redraws. pub(crate) frame_requester: FrameRequester, + + /// Whether the bottom pane should accept user input. pub(crate) has_input_focus: bool, + + /// Whether the terminal supports enhanced keyboard flags. pub(crate) enhanced_keys_supported: bool, + + /// Placeholder text displayed when the composer is empty. pub(crate) placeholder_text: String, + + /// Whether to disable paste burst detection. pub(crate) disable_paste_burst: bool, + + /// Whether animation effects are enabled for this session. pub(crate) animations_enabled: bool, + + /// Skills made available for `@`-mention completion. pub(crate) skills: Option>, } impl BottomPane { + /// Construct a new bottom pane with an initialized composer and empty view stack. + /// + /// Skill mentions are applied up front so the composer can offer completions immediately. pub fn new(params: BottomPaneParams) -> Self { let BottomPaneParams { app_event_tx, @@ -179,43 +250,61 @@ impl BottomPane { } } + /// Update the skill list used for `@` mention completion. + /// + /// This refreshes the composer state and schedules a redraw. pub fn set_skills(&mut self, skills: Option>) { self.composer.set_skill_mentions(skills); self.request_redraw(); } + /// Enable or disable steer mode in the composer. + /// + /// This toggles additional composer affordances without touching view state. pub fn set_steer_enabled(&mut self, enabled: bool) { self.composer.set_steer_enabled(enabled); } + /// Return the status widget if one is currently active. pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } + /// Return the active skill list, if configured. pub fn skills(&self) -> Option<&Vec> { self.composer.skills() } + /// Return the cached context window percent for tests. #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option { self.context_window_percent } + /// Return the cached context window token count for tests. #[cfg(test)] pub(crate) fn context_window_used_tokens(&self) -> Option { self.context_window_used_tokens } + /// Return the currently active view, if any. fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } + /// Push a new view onto the view stack and schedule a redraw. fn push_view(&mut self, view: Box) { self.view_stack.push(view); self.request_redraw(); } /// Forward a key event to the active view or the composer. + /// + /// The routing flow is: + /// - If a modal view is active, it gets the key first and may complete itself. + /// - Otherwise, Esc may trigger an interrupt while a task is running. + /// - Finally, the key is forwarded to the composer, which may request redraws or delayed + /// paste-burst flushing. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. if let Some(view) = self.view_stack.last_mut() { @@ -286,6 +375,10 @@ impl BottomPane { } } + /// Forward a paste event to the active view or the composer. + /// + /// This requests a redraw when the active surface reports changes and closes a modal when it + /// marks itself complete. pub fn handle_paste(&mut self, pasted: String) { if let Some(view) = self.view_stack.last_mut() { let needs_redraw = view.handle_paste(pasted); @@ -303,6 +396,7 @@ impl BottomPane { } } + /// Insert raw text into the composer at the current cursor position. pub(crate) fn insert_str(&mut self, text: &str) { self.composer.insert_str(text); self.request_redraw(); @@ -314,6 +408,7 @@ impl BottomPane { self.request_redraw(); } + /// Enable or disable the composer input and optionally update its placeholder. #[allow(dead_code)] pub(crate) fn set_composer_input_enabled( &mut self, @@ -324,6 +419,7 @@ impl BottomPane { self.request_redraw(); } + /// Clear composer contents in response to a Ctrl+C press. pub(crate) fn clear_composer_for_ctrl_c(&mut self) { self.composer.clear_for_ctrl_c(); self.request_redraw(); @@ -334,15 +430,18 @@ impl BottomPane { self.composer.current_text() } + /// Return the composer text, including any queued submission. pub(crate) fn composer_text_with_pending(&self) -> String { self.composer.current_text_with_pending() } + /// Apply an edit produced by the external editor workflow. pub(crate) fn apply_external_edit(&mut self, text: String) { self.composer.apply_external_edit(text); self.request_redraw(); } + /// Override footer hint text until the override is cleared. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { self.composer.set_footer_hint_override(items); self.request_redraw(); @@ -365,6 +464,9 @@ impl BottomPane { /// allowed), while the bottom pane owns rendering. We also schedule a redraw /// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user /// stops typing and no other events trigger a draw. + /// + /// The expiration timer uses the current Tokio runtime when available and falls back to a + /// thread-based timer in tests. pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) { if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { return; @@ -395,22 +497,26 @@ impl BottomPane { self.request_redraw(); } + /// Return whether the quit-shortcut hint is currently visible (tests only). #[cfg(test)] pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { self.composer.quit_shortcut_hint_visible() } + /// Return whether the status indicator is currently visible (tests only). #[cfg(test)] pub(crate) fn status_indicator_visible(&self) -> bool { self.status.is_some() } + /// Show the Esc backtrack hint and ensure the composer footer updates. pub(crate) fn show_esc_backtrack_hint(&mut self) { self.esc_backtrack_hint = true; self.composer.set_esc_backtrack_hint(true); self.request_redraw(); } + /// Clear the Esc backtrack hint if it is currently shown. pub(crate) fn clear_esc_backtrack_hint(&mut self) { if self.esc_backtrack_hint { self.esc_backtrack_hint = false; @@ -421,6 +527,11 @@ impl BottomPane { // esc_backtrack_hint_visible removed; hints are controlled internally. + /// Update task-running state and adjust footer/status UI accordingly. + /// + /// This updates the composer, lazily creates the status widget when entering a running state, + /// and hides the indicator once the task completes. Modal overlays may temporarily pause the + /// status timer but do not change the running state. pub fn set_task_running(&mut self, running: bool) { let was_running = self.is_task_running; self.is_task_running = running; @@ -453,6 +564,7 @@ impl BottomPane { } } + /// Ensure the status indicator exists without changing task-running state. pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( @@ -464,6 +576,7 @@ impl BottomPane { } } + /// Show or hide the interrupt hint within the status widget. pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(visible); @@ -471,6 +584,9 @@ impl BottomPane { } } + /// Update context window usage and forward it to the composer footer. + /// + /// Updates are cached locally so redundant redraws can be avoided. pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens { @@ -491,11 +607,16 @@ impl BottomPane { } /// Update the queued messages preview shown above the composer. + /// + /// These messages are only rendered while the pane is visible and a turn is active. pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { self.queued_user_messages.messages = queued; self.request_redraw(); } + /// Update the unified exec footer with the current process summaries. + /// + /// Returns early if the summary is unchanged to avoid redundant redraws. pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { if self.unified_exec_footer.set_processes(processes) { self.request_redraw(); @@ -508,10 +629,12 @@ impl BottomPane { self.request_redraw(); } + /// Return true when the composer is empty and has no draft content. pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } + /// Return true when a task is currently running. pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } @@ -537,11 +660,16 @@ impl BottomPane { self.can_launch_external_editor() } + /// Push a view onto the stack so it becomes the active input surface. pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } /// Called when the agent requests user approval. + /// + /// If an active view can consume the request (for example, to update itself), it does so and + /// no new modal is shown. Otherwise this creates a new modal overlay and pauses the status + /// indicator timer so it does not animate behind the modal. pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_approval_request(request) { @@ -561,45 +689,59 @@ impl BottomPane { self.push_view(Box::new(modal)); } + /// Run any cleanup after a modal view completes. + /// + /// Currently this only resumes the status timer, but it is centralized so future modal-related + /// cleanup happens in one place. fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); } + /// Pause the status indicator timer while a modal view is active. fn pause_status_timer_for_modal(&mut self) { if let Some(status) = self.status.as_mut() { status.pause_timer(); } } + /// Resume the status indicator timer after a modal view is dismissed. fn resume_status_timer_after_modal(&mut self) { if let Some(status) = self.status.as_mut() { status.resume_timer(); } } - /// Height (terminal rows) required by the current bottom pane. + /// Request an immediate redraw via the frame requester. pub(crate) fn request_redraw(&self) { self.frame_requester.schedule_frame(); } + /// Request a redraw after the provided delay. pub(crate) fn request_redraw_in(&self, dur: Duration) { self.frame_requester.schedule_frame_in(dur); } // --- History helpers --- + /// Update history metadata used by the composer for navigation. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { self.composer.set_history_metadata(log_id, entry_count); } + /// Flush any pending paste burst if the timeout has elapsed. pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.composer.flush_paste_burst_if_due() } + /// Return true if the composer is currently capturing a paste burst. pub(crate) fn is_in_paste_burst(&self) -> bool { self.composer.is_in_paste_burst() } + /// Apply a response for a history entry and redraw if it changes the buffer. + /// + /// The composer owns the history buffer; this method forwards the data and mirrors redraw + /// requests. pub(crate) fn on_history_entry_response( &mut self, log_id: u64, @@ -615,11 +757,17 @@ impl BottomPane { } } + /// Forward file search results to the composer for highlighting/autocomplete. + /// + /// The composer uses these matches to decorate `@`-mentions and file previews. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { self.composer.on_file_search_result(query, matches); self.request_redraw(); } + /// Attach an image to the composer if no modal view is active. + /// + /// Modal views take exclusive input focus, so attachments are ignored while they are visible. pub(crate) fn attach_image(&mut self, path: PathBuf) { if self.view_stack.is_empty() { self.composer.attach_image(path); @@ -627,10 +775,15 @@ impl BottomPane { } } + /// Drain recently submitted image paths from the composer. pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + /// Build the renderable tree for the active view or composer layout. + /// + /// When no modal is active, the layout stacks status/footer widgets above the composer and + /// inserts a spacer row so the composer is visually separated from any banners. fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) @@ -647,6 +800,7 @@ impl BottomPane { || !self.unified_exec_footer.is_empty() || !self.queued_user_messages.messages.is_empty() { + // Keep a consistent visual gap between banners and the composer. flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -658,12 +812,17 @@ impl BottomPane { } impl Renderable for BottomPane { + /// Render the bottom pane into the provided buffer. fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); } + + /// Return the number of rows needed to render the bottom pane at the given width. fn desired_height(&self, width: u16) -> u16 { self.as_renderable().desired_height(width) } + + /// Return the current cursor position, if any, within the provided area. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } @@ -678,6 +837,7 @@ mod tests { use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; + /// Convert a buffer into a text snapshot for asserting layouts. fn snapshot_buffer(buf: &Buffer) -> String { let mut lines = Vec::new(); for y in 0..buf.area().height { @@ -690,12 +850,14 @@ mod tests { lines.join("\n") } + /// Render the pane into a buffer and return its text snapshot. fn render_snapshot(pane: &BottomPane, area: Rect) -> String { let mut buf = Buffer::empty(area); pane.render(area, &mut buf); snapshot_buffer(&buf) } + /// Build a sample exec approval request for modal tests. fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec { id: "1".to_string(), @@ -705,6 +867,7 @@ mod tests { } } + /// Ensure Ctrl+C dismisses modals without surfacing the quit hint. #[test] fn ctrl_c_on_modal_consumes_without_showing_quit_hint() { let (tx_raw, _rx) = unbounded_channel::(); @@ -728,6 +891,7 @@ mod tests { // live ring removed; related tests deleted. + /// Verify that the transcript overlay is not shown above approval modals. #[test] fn overlay_not_shown_above_approval_modal() { let (tx_raw, _rx) = unbounded_channel::(); @@ -762,6 +926,7 @@ mod tests { ); } + /// Ensure the composer reappears after a denied approval while running. #[test] fn composer_shown_after_denied_while_task_running() { let (tx_raw, _rx) = unbounded_channel::(); @@ -830,6 +995,7 @@ mod tests { ); } + /// Check that the status indicator stays visible during command execution. #[test] fn status_indicator_visible_during_command_execution() { let (tx_raw, _rx) = unbounded_channel::(); @@ -857,6 +1023,7 @@ mod tests { assert!(bufs.contains("• Working"), "expected Working header"); } + /// Snapshot the layout when status and composer fill the available height. #[test] fn status_and_composer_fill_height_without_bottom_padding() { let (tx_raw, _rx) = unbounded_channel::(); @@ -888,6 +1055,7 @@ mod tests { ); } + /// Snapshot the layout for queued messages without the status indicator. #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); @@ -916,6 +1084,7 @@ mod tests { ); } + /// Snapshot the layout when both status and queued messages are present. #[test] fn status_and_queued_messages_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 238c00d600..4e6a7be0a5 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -62,6 +62,8 @@ //! //! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI //! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). +//! The timeout comparison is only meaningful while `last_plain_char_time` is set, so callers must +//! flush before clearing the timing window when switching to non-plain input. //! //! # Retro Capture Details //! @@ -148,29 +150,49 @@ use std::time::Duration; use std::time::Instant; -// Heuristic thresholds for detecting paste-like input bursts. -// Detect quickly to avoid showing typed prefix before paste is recognized +/// Minimum consecutive plain characters required before treating input as paste-like. +/// +/// The threshold is intentionally low so the UI can suppress transient side effects early. const PASTE_BURST_MIN_CHARS: u16 = 3; + +/// Maximum gap between plain characters to keep them in the same burst window. const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); + +/// How long Enter should insert a newline after burst activity subsides. const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); -// Slower paste burts have been observed in windows environments, but ideally -// we want to keep this low + +/// Idle timeout before flushing an active burst as a single paste. +/// +/// Slower paste bursts have been observed in Windows environments, but we keep the threshold low +/// elsewhere to avoid delaying explicit paste detection. #[cfg(not(windows))] const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8); #[cfg(windows)] const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); +/// Tracks a short-lived sequence of plain key events to infer paste-like bursts. #[derive(Default)] pub(crate) struct PasteBurst { + /// Timestamp of the most recent plain char used for burst timing. last_plain_char_time: Option, + + /// Count of consecutive plain chars inside the current burst window. consecutive_plain_char_burst: u16, + + /// Deadline until which Enter should insert a newline instead of submitting. burst_window_until: Option, + + /// Buffered paste text to flush as a single `Paste(String)` event. buffer: String, + + /// Whether new characters should still be buffered into the active burst. active: bool, - // Hold first fast char briefly to avoid rendering flicker + + /// Held first ASCII char used to suppress flicker until the burst is classified. pending_first_char: Option<(char, Instant)>, } +/// Classification decision for a plain `KeyCode::Char` event. pub(crate) enum CharDecision { /// Start buffering and retroactively capture some already-inserted chars. BeginBuffer { retro_chars: u16 }, @@ -183,24 +205,32 @@ pub(crate) enum CharDecision { BeginBufferFromPending, } +/// Result of retroactively grabbing already-inserted UI text. pub(crate) struct RetroGrab { + /// Byte offset into the `before_cursor` slice where the retro grab begins. pub start_byte: usize, + + /// The grabbed substring that should be moved into the burst buffer. pub grabbed: String, } +/// Output of a burst flush operation. pub(crate) enum FlushResult { + /// Emit the buffered content as a single paste operation. Paste(String), + + /// Emit a previously held ASCII char as normal typing. Typed(char), + + /// No flush is due yet. None, } impl PasteBurst { - /// Recommended delay to wait between simulated keypresses (or before - /// scheduling a UI tick) so that a pending fast keystroke is flushed - /// out of the burst detector as normal typed input. + /// Returns the delay needed to exceed the inter-key timeout safely. /// - /// Primarily used by tests and by the TUI to reliably cross the - /// paste-burst timing threshold. + /// Tests and UI tick schedulers use this to ensure a pending ASCII char flushes as normal + /// typing instead of being left in the burst detector. pub fn recommended_flush_delay() -> Duration { PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) } @@ -210,7 +240,7 @@ impl PasteBurst { PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1) } - /// Entry point: decide how to treat a plain char with current timing. + /// Classifies a plain ASCII char using the burst timing heuristic. pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { self.note_plain_char(now); @@ -243,12 +273,12 @@ impl PasteBurst { CharDecision::RetainFirstChar } - /// Like on_plain_char(), but never holds the first char. + /// Classifies a plain non-ASCII char without holding the first character. /// /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can /// feel like dropped input, while still allowing burst-based paste detection. /// - /// Note: This method will only ever return BufferAppend or BeginBuffer. + /// Note: This method only returns `BufferAppend` or `BeginBuffer`. pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option { self.note_plain_char(now); @@ -266,6 +296,7 @@ impl PasteBurst { None } + /// Updates the rolling burst counters for a newly observed plain char. fn note_plain_char(&mut self, now: Instant) { match self.last_plain_char_time { Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { @@ -312,8 +343,7 @@ impl PasteBurst { } } - /// While bursting: accumulate a newline into the buffer instead of - /// submitting the textarea. + /// Appends a newline into the burst buffer when in a burst context. /// /// Returns true if a newline was appended (we are in a burst context), /// false otherwise. @@ -327,18 +357,18 @@ impl PasteBurst { } } - /// Decide if Enter should insert a newline (burst context) vs submit. + /// Returns whether Enter should insert a newline instead of submitting. pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); self.is_active() || in_burst_window } - /// Keep the burst window alive. + /// Extends the burst Enter-suppression window to include `now`. pub fn extend_window(&mut self, now: Instant) { self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); } - /// Begin buffering with retroactively grabbed text. + /// Begins buffering using retroactively grabbed text. pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { if !grabbed.is_empty() { self.buffer.push_str(&grabbed); @@ -347,13 +377,13 @@ impl PasteBurst { self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); } - /// Append a char into the burst buffer. + /// Appends a char into the burst buffer and refreshes the suppression window. pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { self.buffer.push(ch); self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); } - /// Try to append a char into the burst buffer only if a burst is already active. + /// Appends a char only when a burst is active or buffered. /// /// Returns true when the char was captured into the existing burst, false otherwise. pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { @@ -365,8 +395,7 @@ impl PasteBurst { } } - /// Decide whether to begin buffering by retroactively capturing recent - /// chars from the slice before the cursor. + /// Determines whether to begin buffering by retroactively capturing recent text. /// /// Heuristic: if the retro-grabbed slice contains any whitespace or is /// sufficiently long (>= 16 characters), treat it as paste-like to avoid @@ -374,8 +403,7 @@ impl PasteBurst { /// This favors responsiveness and prevents flicker for typical pastes /// (URLs, file paths, multiline text) while not triggering on short words. /// - /// Returns Some(RetroGrab) with the start byte and grabbed text when we - /// decide to buffer retroactively; otherwise None. + /// Returns `Some(RetroGrab)` when retro-capture is warranted; otherwise `None`. pub fn decide_begin_buffer( &mut self, now: Instant, @@ -398,7 +426,7 @@ impl PasteBurst { } } - /// Before applying modified/non-char input: flush buffered burst immediately. + /// Flushes any buffered burst before handling modified or non-char input. pub fn flush_before_modified_input(&mut self) -> Option { if !self.is_active() { return None; @@ -411,7 +439,7 @@ impl PasteBurst { Some(out) } - /// Clear only the timing window and any pending first-char. + /// Clears the timing window and any held first char. /// /// Does not emit or clear the buffered text itself; callers should have /// already flushed (if needed) via one of the flush methods above. @@ -423,17 +451,17 @@ impl PasteBurst { self.pending_first_char = None; } - /// Returns true if we are in any paste-burst related transient state - /// (actively buffering, have a non-empty buffer, or have saved the first - /// fast char while waiting for a potential burst). + /// Returns true when any paste-burst state is still in flight. pub fn is_active(&self) -> bool { self.is_active_internal() || self.pending_first_char.is_some() } + /// Returns whether the burst buffer is currently active or non-empty. fn is_active_internal(&self) -> bool { self.active || !self.buffer.is_empty() } + /// Resets all burst state after an explicit paste has been processed. pub fn clear_after_explicit_paste(&mut self) { self.last_plain_char_time = None; self.consecutive_plain_char_burst = 0; @@ -444,6 +472,7 @@ impl PasteBurst { } } +/// Returns the byte index of the earliest `retro_chars` characters in `before`. pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { if retro_chars == 0 { return before.len(); diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs index 2cabe389b1..04b551fd43 100644 --- a/codex-rs/tui/src/bottom_pane/popup_consts.rs +++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs @@ -1,4 +1,15 @@ -//! Shared popup-related constants for bottom pane widgets. +//! Shared popup constants for bottom-pane overlays. +//! +//! The bottom pane has multiple popups (selection lists, approvals, file search) that should feel +//! consistent in height and in their footer hints. This module centralizes those shared values so +//! individual widgets can avoid hard-coding layout numbers or rephrasing the same hint text. +//! +//! These constants are UI-facing rather than behavioral: they define how popups present +//! themselves, but the owning widget is still responsible for deciding when to show a popup and +//! how to handle its input. +//! +//! The shared footer hint returned here standardizes the `Enter`/`Esc` wording and styling via +//! [`crate::key_hint`], so every popup uses the same key glyph treatment. use crossterm::event::KeyCode; use ratatui::text::Line; @@ -6,10 +17,15 @@ use ratatui::text::Line; use crate::key_hint; /// Maximum number of rows any popup should attempt to display. -/// Keep this consistent across all popups for a uniform feel. +/// +/// Keeping this consistent across popups produces a stable layout as users switch between +/// overlays, even when the underlying list is longer. pub(crate) const MAX_POPUP_ROWS: usize = 8; -/// Standard footer hint text used by popups. +/// Returns the shared footer hint used by bottom-pane popups. +/// +/// The `Enter`/`Esc` hint text is intentionally centralized so all popups render the same wording +/// and key glyph styling via [`crate::key_hint`]. pub(crate) fn standard_popup_hint_line() -> Line<'static> { Line::from(vec![ "Press ".into(), diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 48c3cedfab..80ef9fa449 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,3 +1,27 @@ +//! Parses and expands argument placeholders for custom prompt invocations. +//! +//! The bottom pane supports `/prompts:name ...` commands that expand a saved prompt template with +//! caller-provided arguments. This module parses that invocation, validates required placeholders, +//! and performs the replacement without mutating any UI state. +//! +//! Supported placeholders: +//! +//! - Named placeholders like `$USER`, replaced from `key=value` arguments. +//! - Numeric placeholders like `$1`..`$9`, replaced from positional arguments. +//! - The aggregate `$ARGUMENTS`, which expands to all positional arguments joined by spaces. +//! +//! Named placeholders are detected with a regex and require explicit `key=value` assignments. +//! Numeric placeholders allow positional arguments and can be mixed with `$ARGUMENTS`. Escaped +//! placeholders prefixed with `$$` are treated as literals and preserved as-is so templates can +//! reference dollars verbatim. +//! +//! Named placeholders take precedence: if a prompt declares any `$NAME` tokens (other than +//! `$ARGUMENTS`), the invocation must supply matching `key=value` inputs and positional expansion +//! is skipped entirely. +//! +//! Placeholder detection is deliberately conservative so unrelated `$` sequences (for example in +//! Markdown) are not treated as prompt variables. Escaped placeholders (`$$NAME`) are preserved +//! verbatim in both the named and numeric expansion paths. use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use lazy_static::lazy_static; @@ -7,17 +31,27 @@ use std::collections::HashMap; use std::collections::HashSet; lazy_static! { + /// Regex that matches candidate named placeholders like `$USER`. + /// + /// The pattern is intentionally conservative (uppercase letters, digits, underscore) so it + /// does not accidentally capture `$` sequences used by Markdown or other template syntax. static ref PROMPT_ARG_REGEX: Regex = Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); } +/// Errors raised while parsing `key=value` prompt inputs. +/// +/// These errors are intended for user-facing feedback and preserve the original token text. #[derive(Debug)] pub enum PromptArgsError { + /// A token did not contain `=` and could not be parsed. MissingAssignment { token: String }, + /// A token had an empty key before `=`. MissingKey { token: String }, } impl PromptArgsError { + /// Format a user-facing error message that mentions the original command. fn describe(&self, command: &str) -> String { match self { PromptArgsError::MissingAssignment { token } => format!( @@ -30,12 +64,17 @@ impl PromptArgsError { } } +/// Errors raised while expanding a custom prompt invocation. +/// +/// These errors contain enough context to render a friendly message in the UI. #[derive(Debug)] pub enum PromptExpansionError { + /// Named argument parsing failed for a specific invocation. Args { command: String, error: PromptArgsError, }, + /// A required named placeholder was not provided by the user. MissingArgs { command: String, missing: Vec, @@ -43,6 +82,7 @@ pub enum PromptExpansionError { } impl PromptExpansionError { + /// Render a user-facing message suitable for UI display. pub fn user_message(&self) -> String { match self { PromptExpansionError::Args { command, error } => error.describe(command), @@ -57,8 +97,12 @@ impl PromptExpansionError { } /// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name)` if the line begins with `/` and contains -/// a non-empty name; otherwise returns `None`. +/// +/// Returns `(name, rest_after_name)` if the line begins with `/` and contains a +/// non-empty name; otherwise returns `None`. +/// +/// The parser only splits on whitespace and does not interpret the command name beyond that, so +/// callers must still validate prefixes like `prompts:`. pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { let stripped = line.strip_prefix('/')?; let mut name_end = stripped.len(); @@ -77,15 +121,21 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { } /// Parse positional arguments using shlex semantics (supports quoted tokens). +/// +/// This matches the shell-like quoting used in prompt invocations so quoted +/// values stay intact as a single argument. +/// +/// The resulting list is used for `$1`..`$9` and `$ARGUMENTS` expansion only. pub fn parse_positional_args(rest: &str) -> Vec { Shlex::new(rest).collect() } -/// Extracts the unique placeholder variable names from a prompt template. +/// Extract the unique placeholder variable names from a prompt template. /// /// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` -/// (for example `$USER`). The function returns the variable names without -/// the leading `$`, de-duplicated and in the order of first appearance. +/// (for example `$USER`). The function returns the variable names without the +/// leading `$`, de-duplicated and in the order of first appearance, skipping +/// escaped placeholders and the `$ARGUMENTS` aggregate. pub fn prompt_argument_names(content: &str) -> Vec { let mut seen = HashSet::new(); let mut names = Vec::new(); @@ -106,11 +156,13 @@ pub fn prompt_argument_names(content: &str) -> Vec { names } -/// Parses the `key=value` pairs that follow a custom prompt name. +/// Parse the `key=value` pairs that follow a custom prompt name. /// -/// The input is split using shlex rules, so quoted values are supported -/// (for example `USER="Alice Smith"`). The function returns a map of parsed +/// The input is split using shlex rules, so quoted values are supported (for +/// example `USER="Alice Smith"`). The function returns a map of parsed /// arguments, or an error if a token is missing `=` or if the key is empty. +/// +/// When the same key appears multiple times, the last occurrence wins. pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { let mut map = HashMap::new(); if rest.trim().is_empty() { @@ -129,11 +181,13 @@ pub fn parse_prompt_inputs(rest: &str) -> Result, Prompt Ok(map) } -/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. +/// Expand a `/prompts:name ...` invocation using a matching saved prompt. /// -/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, -/// the function returns `Ok(None)`. On success it returns -/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. +/// If the text does not start with `/prompts:`, or if no prompt named `name` +/// exists, the function returns `Ok(None)`. On success it returns +/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. Named +/// placeholders always win over positional ones, so prompts with `$USER`-style +/// placeholders require `key=value` inputs. pub fn expand_custom_prompt( text: &str, custom_prompts: &[CustomPrompt], @@ -192,7 +246,9 @@ pub fn expand_custom_prompt( Ok(Some(expanded)) } -/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. +/// Detect whether `content` contains numeric placeholders (`$1`..`$9`) or `$ARGUMENTS`. +/// +/// This is a shallow scan used for quick gating; it does not parse escapes. pub fn prompt_has_numeric_placeholders(content: &str) -> bool { if content.contains("$ARGUMENTS") { return true; @@ -211,8 +267,11 @@ pub fn prompt_has_numeric_placeholders(content: &str) -> bool { false } -/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. -/// Returns empty when the command name does not match or when there are no args. +/// Extract positional arguments from `/prompts:name a b` for a given prompt name. +/// +/// Returns empty when the command name does not match or when there are no +/// args, preserving the rule that positional expansion only happens for the +/// selected prompt. pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { let trimmed = line.trim_start(); let Some(rest) = trimmed.strip_prefix('/') else { @@ -234,8 +293,11 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> parse_positional_args(args_str) } -/// If the prompt only uses numeric placeholders and the first line contains -/// positional args for it, expand and return Some(expanded); otherwise None. +/// Expand numeric placeholders when the prompt is numeric-only and arguments are provided. +/// +/// Returns `None` when named placeholders are present, when no numeric +/// placeholders are used, or when the invocation does not include positional +/// arguments. pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, @@ -254,6 +316,9 @@ pub fn expand_if_numeric_with_positional_args( } /// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. +/// +/// `$ARGUMENTS` is expanded lazily so joining happens at most once per call. +/// `$1`..`$9` placeholders are skipped when the corresponding argument is missing. pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { let mut out = String::with_capacity(content.len()); let mut i = 0; @@ -296,13 +361,17 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { out } -/// Constructs a command text for a custom prompt with arguments. +/// Construct `/prompts:name key="" ...` text for named placeholders. +/// /// Returns the text and the cursor position (inside the first double quote). +/// +/// The cursor position is used by the caller to place focus inside the first argument value so +/// users can begin typing immediately. pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); let mut cursor: usize = text.len(); for (i, arg) in args.iter().enumerate() { - text.push_str(format!(" {arg}=\"\"").as_str()); + text.push_str(&format!(" {arg}=\"\"")); if i == 0 { cursor = text.len() - 1; // inside first "" } @@ -314,6 +383,7 @@ pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (Str mod tests { use super::*; + /// Replaces named placeholders with supplied `key=value` arguments. #[test] fn expand_arguments_basic() { let prompts = vec![CustomPrompt { @@ -329,6 +399,7 @@ mod tests { assert_eq!(out, Some("Review Alice changes on main".to_string())); } + /// Ensures shlex parsing preserves quoted values with spaces. #[test] fn quoted_values_ok() { let prompts = vec![CustomPrompt { @@ -347,6 +418,7 @@ mod tests { assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); } + /// Emits a user-facing error when a token is missing `key=value`. #[test] fn invalid_arg_token_reports_error() { let prompts = vec![CustomPrompt { @@ -362,6 +434,7 @@ mod tests { assert!(err.contains("expected key=value")); } + /// Reports missing required keys when some named placeholders are absent. #[test] fn missing_required_args_reports_error() { let prompts = vec![CustomPrompt { @@ -378,6 +451,7 @@ mod tests { assert!(err.contains("BRANCH")); } + /// Skips escaped placeholders when extracting required names. #[test] fn escaped_placeholder_is_ignored() { assert_eq!( @@ -390,6 +464,7 @@ mod tests { ); } + /// Leaves `$$`-escaped placeholders intact during expansion. #[test] fn escaped_placeholder_remains_literal() { let prompts = vec![CustomPrompt { diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index ae33aeada4..394ad45597 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -1,3 +1,20 @@ +//! Renders the queue of user messages entered while a turn is still running. +//! +//! The queue is visual-only state: it shows what the user typed while the app +//! was busy and offers a hint for editing the most recent queued message. It +//! does not own submission behavior; it only wraps, truncates, and styles the +//! queued strings for display in the bottom pane. +//! +//! Correctness relies on preserving message order and on keeping the hint +//! aligned with the actual edit action (currently `Alt+Up`). The rendering path +//! never mutates queue contents; callers remain responsible for enqueueing and +//! dequeuing messages as turns begin or end. +//! +//! Each message is rendered with a dimmed gutter (`↳`), wrapped to the available +//! width, and truncated to three lines with an ellipsis line when additional +//! content exists. A final hint line is appended only when there is at least one +//! queued message. + use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -10,18 +27,33 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; -/// Widget that displays a list of user messages queued while a turn is in progress. +/// Displays user messages queued while a turn is in progress. +/// +/// The widget truncates each queued message to three wrapped lines, appending a +/// dimmed ellipsis when more content exists, then ends with a keyboard hint for +/// editing the latest queued message. pub(crate) struct QueuedUserMessages { + /// Ordered queued messages, oldest first. + /// + /// New messages are appended by the caller; the renderer preserves this + /// ordering so users can see the queue in chronological sequence. pub messages: Vec, } impl QueuedUserMessages { + /// Creates an empty queue widget with no visible height. pub(crate) fn new() -> Self { Self { messages: Vec::new(), } } + /// Converts the queued messages into a renderable paragraph for a width. + /// + /// Returns an empty renderable when there is insufficient space to display + /// the gutters or when there are no messages to show. Each message is + /// wrapped with a two-character gutter, truncated to three lines, and + /// followed by an ellipsis line when content was clipped. fn as_renderable(&self, width: u16) -> Box { if self.messages.is_empty() || width < 4 { return Box::new(()); @@ -59,6 +91,7 @@ impl QueuedUserMessages { } impl Renderable for QueuedUserMessages { + /// Renders the queued messages paragraph into the given area. fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; @@ -67,6 +100,10 @@ impl Renderable for QueuedUserMessages { self.as_renderable(area.width).render(area, buf); } + /// Returns the desired height for the current queue at the given width. + /// + /// The height matches the wrapped, truncated paragraph returned by + /// [`QueuedUserMessages::as_renderable`]. fn desired_height(&self, width: u16) -> u16 { self.as_renderable(width).desired_height(width) } @@ -78,12 +115,14 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; + /// Verifies empty queues have no height. #[test] fn desired_height_empty() { let queue = QueuedUserMessages::new(); assert_eq!(queue.desired_height(40), 0); } + /// Ensures a single short message fits in two lines (message + hint). #[test] fn desired_height_one_message() { let mut queue = QueuedUserMessages::new(); @@ -91,6 +130,7 @@ mod tests { assert_eq!(queue.desired_height(40), 2); } + /// Snapshots the rendering for one queued message. #[test] fn render_one_message() { let mut queue = QueuedUserMessages::new(); @@ -102,6 +142,7 @@ mod tests { assert_snapshot!("render_one_message", format!("{buf:?}")); } + /// Snapshots the rendering for two queued messages. #[test] fn render_two_messages() { let mut queue = QueuedUserMessages::new(); @@ -114,6 +155,7 @@ mod tests { assert_snapshot!("render_two_messages", format!("{buf:?}")); } + /// Snapshots the rendering when more than three messages are queued. #[test] fn render_more_than_three_messages() { let mut queue = QueuedUserMessages::new(); @@ -128,6 +170,7 @@ mod tests { assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); } + /// Snapshots wrapping behavior for a long queued message. #[test] fn render_wrapped_message() { let mut queue = QueuedUserMessages::new(); @@ -142,6 +185,7 @@ mod tests { assert_snapshot!("render_wrapped_message", format!("{buf:?}")); } + /// Snapshots rendering of queued messages containing explicit newlines. #[test] fn render_many_line_message() { let mut queue = QueuedUserMessages::new(); diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs index a9728d1a0d..0aebe0c299 100644 --- a/codex-rs/tui/src/bottom_pane/scroll_state.rs +++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs @@ -1,16 +1,39 @@ -/// Generic scroll/selection state for a vertical list menu. +//! Scroll and selection bookkeeping for bottom-pane list popups. +//! +//! This module provides a tiny state machine used by selection lists to track +//! the highlighted row and the first visible index of the scroll window. It +//! encapsulates wrap-around navigation and the logic to keep the selected row +//! within the currently visible range. +//! +//! The state is intentionally generic and does not know about item content or +//! rendering; callers must supply the list length and visible row count. +//! +//! Callers are expected to clamp selection whenever the list length changes and +//! to call [`ScrollState::ensure_visible`] after navigation so `scroll_top` +//! tracks the highlighted row. + +/// Scroll and selection state for a vertical list menu. /// -/// Encapsulates the common behavior of a selectable list that supports: -/// - Optional selection (None when list is empty) -/// - Wrap-around navigation on Up/Down -/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible +/// The state tracks the selected index and the first visible row in the list +/// viewport. Callers are responsible for keeping it in sync with their item +/// collection and the number of visible rows they can render. Navigation methods +/// only update the selection; callers must invoke [`ScrollState::ensure_visible`] +/// to keep the scroll window aligned. #[derive(Debug, Default, Clone, Copy)] pub(crate) struct ScrollState { + /// Currently selected row in the full list, or `None` when the list is empty. pub selected_idx: Option, + /// Index of the first visible row in the list viewport. + /// + /// When the list is non-empty, this is expected to stay within `0..len`. pub scroll_top: usize, } impl ScrollState { + /// Creates a fresh state with no selection and a zero scroll offset. + /// + /// Call [`ScrollState::clamp_selection`] once the list has items to set an + /// initial selection. pub fn new() -> Self { Self { selected_idx: None, @@ -18,13 +41,16 @@ impl ScrollState { } } - /// Reset selection and scroll. + /// Resets selection and scroll position back to the initial state. pub fn reset(&mut self) { self.selected_idx = None; self.scroll_top = 0; } - /// Clamp selection to be within the [0, len-1] range, or None when empty. + /// Clamps selection to the valid range for a list of the given length. + /// + /// If the list is empty, the selection is cleared and the scroll offset is + /// reset to zero. pub fn clamp_selection(&mut self, len: usize) { self.selected_idx = match len { 0 => None, @@ -35,7 +61,11 @@ impl ScrollState { } } - /// Move selection up by one, wrapping to the bottom when necessary. + /// Moves selection up by one row, wrapping to the bottom when needed. + /// + /// If the list is empty, the selection is cleared and the scroll offset is + /// reset to zero. This does not adjust `scroll_top`; call + /// [`ScrollState::ensure_visible`] after moving. pub fn move_up_wrap(&mut self, len: usize) { if len == 0 { self.selected_idx = None; @@ -49,7 +79,11 @@ impl ScrollState { }); } - /// Move selection down by one, wrapping to the top when necessary. + /// Moves selection down by one row, wrapping to the top when needed. + /// + /// If the list is empty, the selection is cleared and the scroll offset is + /// reset to zero. This does not adjust `scroll_top`; call + /// [`ScrollState::ensure_visible`] after moving. pub fn move_down_wrap(&mut self, len: usize) { if len == 0 { self.selected_idx = None; @@ -62,8 +96,10 @@ impl ScrollState { }); } - /// Adjust `scroll_top` so that the current `selected_idx` is visible within - /// the window of `visible_rows`. + /// Adjusts `scroll_top` so the selected row stays within the visible window. + /// + /// The caller supplies the total list length and number of visible rows; if + /// either is zero, the scroll position resets to zero. pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { if len == 0 || visible_rows == 0 { self.scroll_top = 0; @@ -84,10 +120,13 @@ impl ScrollState { } } +/// Snapshot-style tests for wrap-around navigation and visibility tracking. #[cfg(test)] mod tests { use super::ScrollState; + use pretty_assertions::assert_eq; + /// Drives the selection past each edge and asserts it remains visible. #[test] fn wrap_navigation_and_visibility() { let mut s = ScrollState::new(); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index c1675b01aa..3d3b0ff5c3 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -1,7 +1,25 @@ +//! Shared rendering helpers for bottom-pane selection popups. +//! +//! This module renders [`GenericDisplayRow`] values into a popup area, aligning +//! descriptions to a shared column, applying fuzzy-match highlights, and +//! styling the active selection. The renderer is presentation-only: callers own +//! the filtered row list and the [`ScrollState`] that decides which rows are +//! visible. +//! +//! The layout is intentionally manual rather than using `Table` widgets so it +//! can compute wrapping and truncation consistently across popups. It treats +//! `match_indices` as character indices into `name` and pads descriptions based +//! on the widest visible name, including disabled markers when present. Selected +//! rows are restyled with a cyan bold highlight so the same rendering logic +//! applies to both active and inactive entries. Height calculations mirror the +//! wrapping rules used at render time so callers can size popups without +//! reimplementing layout logic. +//! +//! Callers should treat `match_indices` as character indices, not byte offsets, +//! and should keep `ScrollState` synchronized with the list length so the visible +//! window logic stays stable. use ratatui::buffer::Buffer; use ratatui::layout::Rect; -// Note: Table-based layout previously used Constraint; the manual renderer -// below no longer requires it. use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; @@ -15,17 +33,29 @@ use crate::key_hint::KeyBinding; use super::scroll_state::ScrollState; -/// A generic representation of a display row for selection popups. +/// Describes one row of selectable content in a popup list. #[derive(Default)] pub(crate) struct GenericDisplayRow { + /// Primary label rendered at the start of the row. pub name: String, + /// Optional shortcut rendered in parentheses after the name. pub display_shortcut: Option, - pub match_indices: Option>, // indices to bold (char positions) - pub description: Option, // optional grey text after the name - pub disabled_reason: Option, // optional disabled message - pub wrap_indent: Option, // optional indent for wrapped lines + /// Character indices within `name` that should be bolded for matches. + /// + /// Indices refer to the `char` positions in `name`, not byte offsets. + pub match_indices: Option>, + /// Optional secondary description rendered in a dim style. + pub description: Option, + /// Optional disabled message, shown next to the name and in the description column. + pub disabled_reason: Option, + /// Optional indentation (in spaces) for wrapped lines; defaults to the description column. + pub wrap_indent: Option, } +/// Wrap a styled line to the given width using zero indentation. +/// +/// This is a convenience helper for popups that want wrapping without aligned +/// continuation lines. pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -37,12 +67,16 @@ pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec) -> usize { line.iter() .map(|span| UnicodeWidthStr::width(span.content.as_ref())) .sum() } +/// Truncate a line to `max_width` cells without adding an ellipsis. +/// +/// Span styles are preserved for any characters that fit within the limit. fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { if max_width == 0 { return Line::from(Vec::>::new()); @@ -91,6 +125,10 @@ fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static Line::from(spans_out) } +/// Truncate a line to `max_width`, appending an ellipsis when it overflows. +/// +/// The ellipsis inherits the style of the final visible span to keep emphasis +/// consistent with the truncated content. fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize) -> Line<'static> { if max_width == 0 { return Line::from(Vec::>::new()); @@ -108,9 +146,12 @@ fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize Line::from(spans) } -/// Compute a shared description-column start based on the widest visible name -/// plus two spaces of padding. Ensures at least one column is left for the -/// description. +/// Compute a shared description-column start for the currently visible rows. +/// +/// The column is based on the widest visible name plus two spaces of padding +/// and is clamped to leave at least one column available for the description. +/// Using only the visible range keeps wrapped alignment stable as the user +/// scrolls. fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, @@ -139,6 +180,9 @@ fn compute_desc_col( } /// Determine how many spaces to indent wrapped lines for a row. +/// +/// When `wrap_indent` is not set, rows with descriptions or disabled reasons +/// align their wrapped content under the shared description column. fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { let max_indent = max_width.saturating_sub(1) as usize; let indent = row.wrap_indent.unwrap_or_else(|| { @@ -151,9 +195,11 @@ fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usiz indent.min(max_indent) } -/// Build the full display line for a row with the description padded to start -/// at `desc_col`. Applies fuzzy-match bolding when indices are present and -/// dims the description. +/// Build the full display line for a row with description padding. +/// +/// The name is truncated to reserve space for the description column, match +/// indices are bolded, disabled markers are appended, and the description is +/// dimmed and aligned to `desc_col`. fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { let combined_description = match (&row.description, &row.disabled_reason) { (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), @@ -231,8 +277,13 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { Line::from(full_spans) } -/// Render a list of rows using the provided ScrollState, with shared styling -/// and behavior for selection popups. +/// Render a list of rows using the provided [`ScrollState`]. +/// +/// The renderer computes the visible range, derives a shared description column, +/// and then wraps each row to the available width while keeping the selected row +/// styled in cyan bold. If `rows_all` is empty, the `empty_message` is rendered +/// in a dim italic style. Selection and scroll state are resolved against the +/// item list so wrapping remains item-based even when rows span multiple lines. pub(crate) fn render_rows( area: Rect, buf: &mut Buffer, @@ -319,6 +370,10 @@ pub(crate) fn render_rows( } /// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. +/// +/// This shares the same visible-range and selection rules as [`render_rows`], +/// but uses single-line truncation instead of wrapping for dense popups. The +/// description column logic still applies so alignment matches wrapped views. pub(crate) fn render_rows_single_line( area: Rect, buf: &mut Buffer, @@ -384,10 +439,12 @@ pub(crate) fn render_rows_single_line( } } -/// Compute the number of terminal rows required to render up to `max_results` -/// items from `rows_all` given the current scroll/selection state and the -/// available `width`. Accounts for description wrapping and alignment so the -/// caller can allocate sufficient vertical space. +/// Compute the number of terminal rows required to render the visible items. +/// +/// This mirrors the wrapping and alignment logic used by [`render_rows`], using +/// the same description column alignment and wrapping options. The returned +/// height is clamped to at least one row to account for the empty-state message +/// when there are no rows to render. pub(crate) fn measure_rows_height( rows_all: &[GenericDisplayRow], state: &ScrollState, diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 867c10a8bf..6682d0809c 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -1,3 +1,21 @@ +//! Renders the skill selection popup for the bottom pane. +//! +//! This module owns the transient UI state needed to filter, rank, and render a +//! list of [`SkillMetadata`] entries as the user types a query. It is +//! responsible for fuzzy matching against display names, tracking scroll and +//! selection state, and building single-line rows that the shared selection +//! renderer can draw, including truncating long display names to keep the list +//! compact. +//! +//! It does not own the underlying skill registry, execute selections, or apply +//! actions. Callers provide the skill list and read the selected entry to drive +//! higher-level behavior. Sorting is deterministic: results are ordered by +//! fuzzy score and then display name so keyboard navigation remains stable +//! across re-renders. The popup height calculation reserves space for the +//! one-line footer hint when enough vertical space is available. +//! +//! Display names are truncated to a fixed width to keep the list compact, while +//! descriptions remain intact so users can still distinguish similar skills. use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; @@ -19,13 +37,23 @@ use codex_core::skills::model::SkillMetadata; use crate::text_formatting::truncate_text; +/// Shows a filterable list of skills with a scrollable selection cursor. +/// +/// The popup keeps the current query, derived selection state, and the skill +/// metadata needed to render rows. It does not mutate the skills themselves; +/// callers are expected to update the list and read back the selection when the +/// user confirms. pub(crate) struct SkillPopup { + /// Current user query used to filter and rank skills. query: String, + /// Skill metadata entries provided by the caller for display and selection. skills: Vec, + /// Scroll and selection state for the filtered list. state: ScrollState, } impl SkillPopup { + /// Creates a popup seeded with the available skills and an empty query. pub(crate) fn new(skills: Vec) -> Self { Self { query: String::new(), @@ -34,34 +62,43 @@ impl SkillPopup { } } + /// Replaces the skill list and keeps the selection cursor in range. pub(crate) fn set_skills(&mut self, skills: Vec) { self.skills = skills; self.clamp_selection(); } + /// Updates the query string and re-clamps the selection to filtered rows. pub(crate) fn set_query(&mut self, query: &str) { self.query = query.to_string(); self.clamp_selection(); } + /// Returns the height required to render the list plus footer padding. + /// + /// This is based on the number of filtered rows, clamped to the popup + /// maximum, plus two rows reserved for the spacer and footer hint. pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { let rows = self.rows_from_matches(self.filtered()); let visible = rows.len().clamp(1, MAX_POPUP_ROWS); (visible as u16).saturating_add(2) } + /// Moves the selection up, wrapping to the bottom when needed. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + /// Moves the selection down, wrapping to the top when needed. pub(crate) fn move_down(&mut self) { let len = self.filtered_items().len(); self.state.move_down_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + /// Returns the skill metadata for the currently selected row, if any. pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { let matches = self.filtered_items(); let idx = self.state.selected_idx?; @@ -69,16 +106,23 @@ impl SkillPopup { self.skills.get(*skill_idx) } + /// Clamps the selection index and scroll window to the filtered list. fn clamp_selection(&mut self) { let len = self.filtered_items().len(); self.state.clamp_selection(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } + /// Collects the indices of skills that match the current query. fn filtered_items(&self) -> Vec { self.filtered().into_iter().map(|(idx, _, _)| idx).collect() } + /// Converts matched skills into display rows for the shared renderer. + /// + /// Display names are truncated to keep the popup compact, while the + /// description remains unmodified so the underlying metadata is still + /// visible to the user. fn rows_from_matches( &self, matches: Vec<(usize, Option>, i32)>, @@ -101,6 +145,12 @@ impl SkillPopup { .collect() } + /// Returns the fuzzy-matched skills with optional highlight indices. + /// + /// An empty query yields all skills without match indices. Otherwise the + /// display name is matched first, with a fallback to the raw `name` when it + /// differs, and results are sorted by score then display name to ensure + /// stable ordering. fn filtered(&self) -> Vec<(usize, Option>, i32)> { let filter = self.query.trim(); let mut out: Vec<(usize, Option>, i32)> = Vec::new(); @@ -136,6 +186,11 @@ impl SkillPopup { } impl WidgetRef for SkillPopup { + /// Renders the list of skills and the footer hint when space allows. + /// + /// The list uses a single-line renderer with an inset so rows line up with + /// other bottom-pane popups, and the footer hint is only shown when the + /// area is tall enough to include a spacer row. fn render_ref(&self, area: Rect, buf: &mut Buffer) { let (list_area, hint_area) = if area.height > 2 { let [list_area, _spacer_area, hint_area] = Layout::vertical([ @@ -169,6 +224,7 @@ impl WidgetRef for SkillPopup { } } +/// Builds the footer hint line describing the selection shortcuts. fn skill_popup_hint_line() -> Line<'static> { Line::from(vec![ "Press ".into(), @@ -179,6 +235,7 @@ fn skill_popup_hint_line() -> Line<'static> { ]) } +/// Returns the display name shown in the popup for a skill. fn skill_display_name(skill: &SkillMetadata) -> &str { skill .interface @@ -187,6 +244,7 @@ fn skill_display_name(skill: &SkillMetadata) -> &str { .unwrap_or(&skill.name) } +/// Returns the short description shown under the display name, if any. fn skill_description(skill: &SkillMetadata) -> &str { skill .interface diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 903ebe9f82..de8817c69d 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,3 +1,16 @@ +//! Minimal multi-line text editor used by bottom-pane input widgets. +//! +//! The text area owns an editable string, cursor state, and a cache of wrapped +//! lines for rendering. It handles common terminal key bindings (including +//! control-key fallbacks and word-wise navigation) and protects atomic "elements" +//! such as placeholders from partial edits by treating them as indivisible +//! ranges. Cursor movement is clamped to UTF-8 boundaries outside those +//! elements so edits never split placeholders. Rendering is line-wrapped using +//! the same algorithm as other bottom pane views and honors a scroll offset +//! stored in [`TextAreaState`]. The editor also maintains a kill buffer for +//! Emacs-style delete/yank commands and highlights element ranges during +//! rendering so placeholders remain visually distinct. + use crate::key_hint::is_altgr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -15,40 +28,72 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +/// Characters treated as word boundaries for word-wise navigation and deletion. const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; +/// Returns whether a character should be considered a word separator. fn is_word_separator(ch: char) -> bool { WORD_SEPARATORS.contains(ch) } +/// Atomic range of text that should be edited as a single unit. #[derive(Debug, Clone)] struct TextElement { + /// Byte range of the element payload within `text`. + /// + /// Ranges are expected to align to UTF-8 boundaries and must not overlap. range: Range, } +/// Editable text buffer with cursor, wrapping cache, and atomic element support. +/// +/// The text area is responsible for cursor movement, text insertion/deletion, +/// and preserving the integrity of atomic elements (such as placeholders) +/// during edits. Cursor positions are byte offsets that are always clamped to +/// UTF-8 boundaries outside any element, and element ranges are kept sorted to +/// simplify navigation and rendering. Rendering relies on wrapped line ranges +/// cached per width to keep cursor positioning and scrolling consistent with +/// the UI layout. #[derive(Debug)] pub(crate) struct TextArea { + /// Full text buffer, including newlines. text: String, + /// Cursor position in byte offsets within `text`. cursor_pos: usize, + /// Cached wrapped lines for a given width. wrap_cache: RefCell>, + /// Preferred display column for vertical cursor movement. preferred_col: Option, + /// Atomic elements that should not be split by edits. elements: Vec, + /// Kill buffer used by Emacs-style kill/yank commands. kill_buffer: String, } +/// Cached wrapping information for a specific terminal width. #[derive(Debug, Clone)] struct WrapCache { + /// Width used to compute the cached line ranges. width: u16, + /// Byte ranges of each wrapped line in `text`. + /// + /// The ranges include any trailing newline so renderers can decide whether + /// to display or trim it. lines: Vec>, } +/// Scroll state for a rendered [`TextArea`]. #[derive(Debug, Default, Clone, Copy)] pub(crate) struct TextAreaState { /// Index into wrapped lines of the first visible line. + /// + /// This index is updated by rendering methods so callers can persist it + /// across frames. scroll: u16, } impl TextArea { + /// Creates an empty text area with no elements or selection state. pub fn new() -> Self { Self { text: String::new(), @@ -60,6 +105,7 @@ impl TextArea { } } + /// Replaces the entire buffer and resets cursor, elements, and caches. pub fn set_text(&mut self, text: &str) { self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); @@ -70,14 +116,17 @@ impl TextArea { self.kill_buffer.clear(); } + /// Returns the full text buffer. pub fn text(&self) -> &str { &self.text } + /// Inserts text at the current cursor position. pub fn insert_str(&mut self, text: &str) { self.insert_str_at(self.cursor_pos, text); } + /// Inserts text at the given position, clamping to element boundaries. pub fn insert_str_at(&mut self, pos: usize, text: &str) { let pos = self.clamp_pos_for_insertion(pos); self.text.insert_str(pos, text); @@ -89,11 +138,13 @@ impl TextArea { self.preferred_col = None; } + /// Replaces a range with new text, expanding to element boundaries. pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { let range = self.expand_range_to_element_boundaries(range); self.replace_range_raw(range, text); } + /// Replaces a range without expanding it to element boundaries. fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { assert!(range.start <= range.end); let start = range.start.clamp(0, self.text.len()); @@ -127,20 +178,24 @@ impl TextArea { self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); } + /// Returns the current cursor position in bytes. pub fn cursor(&self) -> usize { self.cursor_pos } + /// Moves the cursor to a new byte position, clamped to valid boundaries. pub fn set_cursor(&mut self, pos: usize) { self.cursor_pos = pos.clamp(0, self.text.len()); self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.preferred_col = None; } + /// Returns the number of wrapped lines needed to render at the given width. pub fn desired_height(&self, width: u16) -> u16 { self.wrapped_lines(width).len() as u16 } + /// Computes the cursor position without supplying a scroll state. #[cfg_attr(not(test), allow(dead_code))] pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.cursor_pos_with_state(area, TextAreaState::default()) @@ -160,15 +215,18 @@ impl TextArea { Some((area.x + col, area.y + screen_row)) } + /// Returns true if the buffer is empty. pub fn is_empty(&self) -> bool { self.text.is_empty() } + /// Returns the display column of the cursor within its logical line. fn current_display_col(&self) -> usize { let bol = self.beginning_of_current_line(); self.text[bol..self.cursor_pos].width() } + /// Finds the wrapped line index that contains the given position. fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { // partition_point returns the index of the first element for which // the predicate is false, i.e. the count of elements with start <= pos. @@ -176,6 +234,7 @@ impl TextArea { if idx == 0 { None } else { Some(idx - 1) } } + /// Moves the cursor to the closest grapheme at the desired display column. fn move_to_display_col_on_line( &mut self, line_start: usize, @@ -196,23 +255,34 @@ impl TextArea { self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); } + /// Returns the byte offset of the start of the line containing `pos`. fn beginning_of_line(&self, pos: usize) -> usize { self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) } + + /// Returns the byte offset of the start of the current line. fn beginning_of_current_line(&self) -> usize { self.beginning_of_line(self.cursor_pos) } + /// Returns the byte offset of the end of the line containing `pos`. fn end_of_line(&self, pos: usize) -> usize { self.text[pos..] .find('\n') .map(|i| i + pos) .unwrap_or(self.text.len()) } + + /// Returns the byte offset of the end of the current line. fn end_of_current_line(&self) -> usize { self.end_of_line(self.cursor_pos) } + /// Applies a key event to the editor, including navigation and editing. + /// + /// The handler merges terminal-specific control fallbacks with Emacs-style + /// editing shortcuts, then falls back to literal character insertion when + /// appropriate. pub fn input(&mut self, event: KeyEvent) { match event { // Some terminals (or configurations) send Control key chords as @@ -451,7 +521,10 @@ impl TextArea { } } - // ####### Input Functions ####### + /// Deletes up to `n` grapheme clusters before the cursor. + /// + /// Deletion respects atomic element boundaries so placeholders are removed + /// as a unit instead of being split. pub fn delete_backward(&mut self, n: usize) { if n == 0 || self.cursor_pos == 0 { return; @@ -466,6 +539,10 @@ impl TextArea { self.replace_range(target..self.cursor_pos, ""); } + /// Deletes up to `n` grapheme clusters after the cursor. + /// + /// Deletion respects atomic element boundaries so placeholders are removed + /// as a unit instead of being split. pub fn delete_forward(&mut self, n: usize) { if n == 0 || self.cursor_pos >= self.text.len() { return; @@ -480,6 +557,7 @@ impl TextArea { self.replace_range(self.cursor_pos..target, ""); } + /// Deletes the word immediately before the cursor. pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); self.kill_range(start..self.cursor_pos); @@ -497,6 +575,7 @@ impl TextArea { } } + /// Deletes to the end of the current line, including a trailing newline. pub fn kill_to_end_of_line(&mut self) { let eol = self.end_of_current_line(); let range = if self.cursor_pos == eol { @@ -514,6 +593,7 @@ impl TextArea { } } + /// Deletes to the beginning of the current line, preserving the newline. pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); let range = if self.cursor_pos == bol { @@ -527,6 +607,7 @@ impl TextArea { } } + /// Inserts the current kill buffer at the cursor. pub fn yank(&mut self) { if self.kill_buffer.is_empty() { return; @@ -535,6 +616,7 @@ impl TextArea { self.insert_str(&text); } + /// Removes a range and stores it in the kill buffer. fn kill_range(&mut self, range: Range) { let range = self.expand_range_to_element_boundaries(range); if range.start >= range.end { @@ -562,6 +644,10 @@ impl TextArea { self.preferred_col = None; } + /// Moves the cursor one visual line up, preserving the preferred column. + /// + /// When wrapping metadata is available, navigation moves across wrapped + /// lines; otherwise it falls back to logical line boundaries. pub fn move_cursor_up(&mut self) { // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. if let Some((target_col, maybe_line)) = { @@ -625,6 +711,10 @@ impl TextArea { } } + /// Moves the cursor one visual line down, preserving the preferred column. + /// + /// When wrapping metadata is available, navigation moves across wrapped + /// lines; otherwise it falls back to logical line boundaries. pub fn move_cursor_down(&mut self) { // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. if let Some((target_col, move_to_last)) = { @@ -693,6 +783,7 @@ impl TextArea { } } + /// Moves the cursor to the start of the current line. pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { let bol = self.beginning_of_current_line(); if move_up_at_bol && self.cursor_pos == bol { @@ -703,6 +794,7 @@ impl TextArea { self.preferred_col = None; } + /// Moves the cursor to the end of the current line. pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { let eol = self.end_of_current_line(); if move_down_at_eol && self.cursor_pos == eol { @@ -713,8 +805,7 @@ impl TextArea { } } - // ===== Text elements support ===== - + /// Returns the payload strings for all tracked elements. pub fn element_payloads(&self) -> Vec { self.elements .iter() @@ -722,6 +813,7 @@ impl TextArea { .collect() } + /// Returns the element payload that starts at the given byte position. pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -797,6 +889,7 @@ impl TextArea { true } + /// Inserts text as an atomic element and places the cursor after it. pub fn insert_element(&mut self, text: &str) { let start = self.clamp_pos_for_insertion(self.cursor_pos); self.insert_str_at(start, text); @@ -806,18 +899,21 @@ impl TextArea { self.set_cursor(end); } + /// Records a new element range and keeps the element list ordered. fn add_element(&mut self, range: Range) { let elem = TextElement { range }; self.elements.push(elem); self.elements.sort_by_key(|e| e.range.start); } + /// Finds the index of the element that strictly contains `pos`. fn find_element_containing(&self, pos: usize) -> Option { self.elements .iter() .position(|e| pos > e.range.start && pos < e.range.end) } + /// Clamps a position to the nearest valid UTF-8 character boundary. fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize { let pos = pos.min(self.text.len()); if self.text.is_char_boundary(pos) { @@ -838,6 +934,7 @@ impl TextArea { } } + /// Clamps a position to a character boundary outside any element. fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize { let pos = self.clamp_pos_to_char_boundary(pos); if let Some(idx) = self.find_element_containing(pos) { @@ -854,6 +951,7 @@ impl TextArea { } } + /// Clamps a position for insertion, snapping to element edges. fn clamp_pos_for_insertion(&self, pos: usize) -> usize { let pos = self.clamp_pos_to_char_boundary(pos); // Do not allow inserting into the middle of an element @@ -872,6 +970,7 @@ impl TextArea { } } + /// Expands a range to fully include any overlapping elements. fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { // Expand to include any intersecting elements fully loop { @@ -894,6 +993,7 @@ impl TextArea { range } + /// Shifts element ranges to account for an insertion or deletion. fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) { // Generic shift: for pure insert, removed = 0; for delete, inserted = 0. let end = at + removed; @@ -919,10 +1019,12 @@ impl TextArea { } } + /// Updates element ranges after replacing a span of text. fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) { self.shift_elements(start, end.saturating_sub(start), inserted_len); } + /// Returns the previous cursor boundary, respecting elements and graphemes. fn prev_atomic_boundary(&self, pos: usize) -> usize { if pos == 0 { return 0; @@ -949,6 +1051,7 @@ impl TextArea { } } + /// Returns the next cursor boundary, respecting elements and graphemes. fn next_atomic_boundary(&self, pos: usize) -> usize { if pos >= self.text.len() { return self.text.len(); @@ -975,6 +1078,7 @@ impl TextArea { } } + /// Returns the start index of the previous word relative to the cursor. pub(crate) fn beginning_of_previous_word(&self) -> usize { let prefix = &self.text[..self.cursor_pos]; let Some((first_non_ws_idx, ch)) = prefix @@ -996,6 +1100,7 @@ impl TextArea { self.adjust_pos_out_of_elements(start, true) } + /// Returns the end index of the next word relative to the cursor. pub(crate) fn end_of_next_word(&self) -> usize { let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) else { @@ -1017,6 +1122,7 @@ impl TextArea { self.adjust_pos_out_of_elements(end, false) } + /// Adjusts a position to the nearest element boundary if it falls inside one. fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { if let Some(idx) = self.find_element_containing(pos) { let e = &self.elements[idx]; @@ -1030,6 +1136,10 @@ impl TextArea { } } + /// Returns wrapped line ranges for the given width, rebuilding the cache as needed. + /// + /// The cache uses the same wrapping algorithm as other bottom-pane widgets + /// so cursor placement and scrolling line up with rendered text. #[expect(clippy::unwrap_used)] fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { // Ensure cache is ready (potentially mutably borrow, then drop) @@ -1087,6 +1197,7 @@ impl TextArea { } impl WidgetRef for &TextArea { + /// Renders the text area without applying scroll state. fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); self.render_lines(area, buf, &lines, 0..lines.len()); @@ -1096,6 +1207,7 @@ impl WidgetRef for &TextArea { impl StatefulWidgetRef for &TextArea { type State = TextAreaState; + /// Renders the text area while updating and honoring scroll state. fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area.height, &lines, state.scroll); @@ -1108,6 +1220,7 @@ impl StatefulWidgetRef for &TextArea { } impl TextArea { + /// Renders a subset of wrapped lines into the given buffer. fn render_lines( &self, area: Rect, @@ -1118,6 +1231,7 @@ impl TextArea { for (row, idx) in range.enumerate() { let r = &lines[idx]; let y = area.y + row as u16; + // `wrap_ranges` includes the trailing newline, so skip it for display. let line_range = r.start..r.end - 1; // Draw base line with default style. buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs index 736ce01114..ed97cdd08b 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -1,3 +1,10 @@ +//! Renders the footer row that summarizes active unified exec processes. +//! +//! The footer shows a terse count of background terminals and instructs users +//! to open `/ps` for details. It intentionally renders nothing when the area is +//! too small or there are no active processes, so it can be composed into +//! layouts without separate visibility logic. + use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -7,17 +14,21 @@ use ratatui::widgets::Paragraph; use crate::live_wrap::take_prefix_by_width; use crate::render::renderable::Renderable; +/// View-model for the unified exec footer line. pub(crate) struct UnifiedExecFooter { + /// Cached command labels for currently running processes. processes: Vec, } impl UnifiedExecFooter { + /// Create an empty footer with no running processes. pub(crate) fn new() -> Self { Self { processes: Vec::new(), } } + /// Replace the tracked process list, returning whether the content changed. pub(crate) fn set_processes(&mut self, processes: Vec) -> bool { if self.processes == processes { return false; @@ -26,10 +37,12 @@ impl UnifiedExecFooter { true } + /// Report whether there are any running processes to summarize. pub(crate) fn is_empty(&self) -> bool { self.processes.is_empty() } + /// Build the single-line footer text, truncated to the available width. fn render_lines(&self, width: u16) -> Vec> { if self.processes.is_empty() || width < 4 { return Vec::new(); @@ -44,6 +57,7 @@ impl UnifiedExecFooter { } impl Renderable for UnifiedExecFooter { + /// Render the footer content into the provided buffer. fn render(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() { return; @@ -52,6 +66,7 @@ impl Renderable for UnifiedExecFooter { Paragraph::new(self.render_lines(area.width)).render(area, buf); } + /// Return the number of rows required to render the footer. fn desired_height(&self, width: u16) -> u16 { self.render_lines(width).len() as u16 } @@ -63,12 +78,14 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; + /// Verifies empty process lists report no visible height. #[test] fn desired_height_empty() { let footer = UnifiedExecFooter::new(); assert_eq!(footer.desired_height(40), 0); } + /// Snapshots the footer rendering for a single active process. #[test] fn render_more_sessions() { let mut footer = UnifiedExecFooter::new(); @@ -80,6 +97,7 @@ mod tests { assert_snapshot!("render_more_sessions", format!("{buf:?}")); } + /// Snapshots pluralized output for many active processes. #[test] fn render_many_sessions() { let mut footer = UnifiedExecFooter::new(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bab27efd0a..f9d0816a7e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9,6 +9,11 @@ //! cached, render-only live tail derived from the current active cell so in-flight tool calls are //! visible immediately. //! +//! `ChatWidget` is the primary coordinator between the bottom pane, the protocol event stream, +//! and app-level event requests. It never runs the agent itself; instead it translates user intent +//! into `Op` submissions and reflects backend state changes back into UI state that drives +//! rendering and overlays. +//! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail //! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript @@ -20,6 +25,10 @@ //! is in progress and while MCP server startup is in progress. Those lifecycles are tracked //! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via //! `update_task_running_state`. +//! +//! Additional responsibilities handled here include rate-limit polling and prompts, review-mode +//! entry/exit, notifications, and queueing user input while a turn is in progress so the UI stays +//! responsive without violating turn ordering. use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -112,6 +121,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use tracing::debug; +/// Placeholder model label shown before session configuration completes. const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; use crate::app_event::AppEvent; @@ -184,42 +194,75 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; +/// Title shown in the local shell command helper popup. const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; + +/// Example line shown in the local shell command helper popup. const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; + +/// Default OpenAI API base URL for tooltips and configuration views. const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; -// Track information about an in-flight exec command. + +/// Tracks information about an in-flight exec command. +/// +/// The fields are cached at command start so the transcript can render consistent labels even if +/// later events arrive out of order or without full metadata. struct RunningCommand { + /// Full argv for the running command, as emitted by the backend. command: Vec, + + /// Parsed command tokens for rendering, matched to `command`. parsed_cmd: Vec, + + /// Origin of the command (shell, patch, MCP, etc.) for transcript labeling. source: ExecCommandSource, } +/// Summarizes unified exec activity for display in the transcript. +/// +/// These summaries are aggregated across a turn so repeated unified exec calls can be collapsed +/// into a compact footer section without losing the user-facing command label. struct UnifiedExecProcessSummary { + /// Stable key for de-duplicating summary entries within a turn. key: String, + + /// Human-friendly command label for the transcript footer. command_display: String, } +/// Captures a unified exec wait message for de-duplication. +/// +/// Unified exec emits periodic wait updates; this struct keeps the last display string so the UI +/// can suppress identical repeats. struct UnifiedExecWaitState { + /// Last-emitted waiting message, used to suppress duplicates. command_display: String, } impl UnifiedExecWaitState { + /// Create a de-duplication state for the given display string. fn new(command_display: String) -> Self { Self { command_display } } + /// Return true if the display string matches the last-emitted wait message. fn is_duplicate(&self, command_display: &str) -> bool { self.command_display == command_display } } +/// Tracks consecutive unified exec wait updates to collapse redundant output. #[derive(Clone, Debug)] struct UnifiedExecWaitStreak { + /// Process id for the unified exec group currently in the wait streak. process_id: String, + + /// First non-empty display string observed for this wait streak. command_display: Option, } impl UnifiedExecWaitStreak { + /// Create a new wait streak for the given process and optional command label. fn new(process_id: String, command_display: Option) -> Self { Self { process_id, @@ -227,6 +270,7 @@ impl UnifiedExecWaitStreak { } } + /// Capture the first non-empty display string for this streak, if not yet set. fn update_command_display(&mut self, command_display: Option) { if self.command_display.is_some() { return; @@ -235,6 +279,7 @@ impl UnifiedExecWaitStreak { } } +/// Return true if the exec source is part of a unified exec group. fn is_unified_exec_source(source: ExecCommandSource) -> bool { matches!( source, @@ -242,6 +287,7 @@ fn is_unified_exec_source(source: ExecCommandSource) -> bool { ) } +/// Return true when every parsed command token is a known tool call. fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { !parsed_cmd.is_empty() && parsed_cmd @@ -249,17 +295,30 @@ fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { .all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. })) } +/// Percentage thresholds that trigger rate-limit warnings. const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; + +/// Default model used for the NUX prompt when prompting to switch models. const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; + +/// Usage percentage at which the rate-limit switch prompt should appear. const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +/// Tracks which rate-limit warning thresholds have already been emitted. +/// +/// This prevents duplicate warnings across successive snapshots as usage crosses the same +/// thresholds within a single session. #[derive(Default)] struct RateLimitWarningState { + /// Highest secondary warning index already emitted. secondary_index: usize, + + /// Highest primary warning index already emitted. primary_index: usize, } impl RateLimitWarningState { + /// Compute any new warnings given the latest snapshot percentages. fn take_warnings( &mut self, secondary_used_percent: Option, @@ -318,11 +377,24 @@ impl RateLimitWarningState { } } +/// Format a rate-limit window length (in minutes) into a compact label. +/// +/// This applies a small rounding bias so near-boundary windows (for example, 4h59m vs 5h) map to +/// a stable label instead of oscillating between buckets as snapshots arrive. pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { + /// Number of minutes in an hour. const MINUTES_PER_HOUR: i64 = 60; + + /// Number of minutes in a day. const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; + + /// Number of minutes in a week. const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; + + /// Number of minutes in a 30-day month. const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; + + /// Bias added to avoid thrashing near window boundaries. const ROUNDING_BIAS_MINUTES: i64 = 3; let windows_minutes = windows_minutes.max(0); @@ -341,33 +413,69 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { } /// Common initialization parameters shared by all `ChatWidget` constructors. +/// +/// This struct bundles state that must be consistent between new-session and resume/fork flows, +/// including configuration snapshots, initial prompt overrides, and shared service handles. pub(crate) struct ChatWidgetInit { + /// Session config snapshot applied to the widget. pub(crate) config: Config, + + /// Frame scheduler for time-based redraws. pub(crate) frame_requester: FrameRequester, + + /// Sender for app-level events emitted by the widget. pub(crate) app_event_tx: AppEventSender, + + /// Optional initial prompt text supplied on startup. pub(crate) initial_prompt: Option, + + /// Optional initial image paths supplied on startup. pub(crate) initial_images: Vec, + + /// Whether the terminal supports enhanced keyboard flags. pub(crate) enhanced_keys_supported: bool, + + /// Shared auth manager for login and token refresh. pub(crate) auth_manager: Arc, + + /// Models manager for resolving model metadata and lists. pub(crate) models_manager: Arc, + + /// Feedback sink used by `/feedback`. pub(crate) feedback: codex_feedback::CodexFeedback, + + /// Whether this is the first run of the TUI on this machine. pub(crate) is_first_run: bool, + + /// Optional model override for the initial session. pub(crate) model: Option, } +/// State machine for deciding when to show the rate-limit switch prompt. #[derive(Default)] enum RateLimitSwitchPromptState { + /// No prompt is pending. #[default] Idle, + + /// Threshold reached, waiting to display the prompt. Pending, + + /// Prompt has been shown for the current snapshot. Shown, } +/// Current lifecycle state of the external editor workflow. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { + /// No external editor flow is active. #[default] Closed, + + /// External editor launch has been requested but not yet spawned. Requested, + + /// External editor is active and edits may be applied. Active, } @@ -380,14 +488,37 @@ pub(crate) enum ExternalEditorState { /// It is not responsible for running the agent itself; it reflects progress by updating UI state /// and by sending requests back to codex-core. /// +/// At a high level it: (1) routes incoming protocol events to focused handlers, (2) maintains the +/// streaming/active-cell transcript state, (3) coordinates bottom-pane overlays and queued input, +/// and (4) emits notifications, rate-limit prompts, and review-mode transitions. It also owns the +/// per-session configuration overrides (model, approvals, sandbox policy) that are applied via the +/// UI and must be reflected back into outgoing ops. +/// /// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing /// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting /// active work, arming the double-press quit shortcut, and requesting shutdown-first exit. +/// +/// Long-running or streaming operations may produce interleaved protocol events; this type +/// preserves ordering by deferring interruptive UI changes while a write cycle is active, then +/// drains those changes once the active cell stabilizes. pub(crate) struct ChatWidget { + /// Sender for app-level events requested by the widget. app_event_tx: AppEventSender, + + /// Channel for submitting protocol ops to the active Codex thread. codex_op_tx: UnboundedSender, + + /// Bottom pane responsible for input and modal UI. + /// + /// This owns the composer state and modal overlays while `ChatWidget` orchestrates the + /// higher-level event flow. bottom_pane: BottomPane, + + /// In-flight history cell that is still streaming or being updated. + /// + /// This is flushed into the transcript when a new visible cell arrives or the turn ends. active_cell: Option>, + /// Monotonic-ish counter used to invalidate transcript overlay caching. /// /// The transcript overlay appends a cached "live tail" for the current active cell. Most @@ -398,88 +529,166 @@ pub(crate) struct ChatWidget { /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, + + /// Session config snapshot used for rendering and request defaults. config: Config, + + /// Active model slug for display and prompts. model: Option, + + /// Shared auth manager for token refresh and login flows. auth_manager: Arc, + + /// Models manager for resolving model metadata and availability. models_manager: Arc, + + /// Header model/status banner shown at the top of the transcript. session_header: SessionHeader, + + /// Initial user message to submit on session start, if provided. initial_user_message: Option, + + /// Most recent token usage information from the backend. token_info: Option, + + /// Cached rate-limit snapshot display for `/status`. rate_limit_snapshot: Option, + + /// Current plan type, if known, for UI affordances. plan_type: Option, + + /// State machine tracking rate-limit warning thresholds. + /// + /// This is updated whenever a snapshot arrives and ensures warnings are only emitted once per + /// threshold. rate_limit_warnings: RateLimitWarningState, + + /// State machine for the rate-limit switch prompt flow. + /// + /// This gates whether the model-switch prompt should be shown after high-usage snapshots. rate_limit_switch_prompt: RateLimitSwitchPromptState, + + /// Background task polling rate limits, if active. rate_limit_poller: Option>, - // Stream lifecycle controller + + /// Stream lifecycle controller for the current assistant response. + /// + /// This is used to cancel or finalize streaming output when interrupts or errors occur. stream_controller: Option, + + /// Exec commands keyed by event id while they are running. + /// + /// Stored metadata is used to label transcript rows until the completion event arrives. running_commands: HashMap, + + /// Exec call ids suppressed from transcript rendering (e.g., unified exec grouping). suppressed_exec_calls: HashSet, + + /// Last unified-exec wait message emitted, for de-duplication. last_unified_wait: Option, + + /// Accumulates consecutive unified-exec wait messages. unified_exec_wait_streak: Option, + + /// True when the UI is waiting to emit a task-complete summary. task_complete_pending: bool, + + /// Unified exec process summaries for the current turn. unified_exec_processes: Vec, + /// Tracks whether codex-core currently considers an agent turn to be in progress. /// /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) /// can update the status header without accidentally clearing the spinner for an active turn. agent_turn_running: bool, + /// Tracks per-server MCP startup state while startup is in progress. /// /// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the /// bottom pane is treated as "running" while this is populated, even if no agent turn is /// currently executing. mcp_startup_status: Option>, - // Queue of interruptive UI events deferred during an active write cycle + + /// Queue of interruptive UI events deferred during an active write cycle. + /// + /// This preserves event ordering when streaming output is mutating history cells. interrupts: InterruptManager, - // Accumulates the current reasoning block text to extract a header + + /// Accumulates the current reasoning block text to extract a header. reasoning_buffer: String, - // Accumulates full reasoning content for transcript-only recording + + /// Accumulates full reasoning content for transcript-only recording. full_reasoning_buffer: String, - // Current status header shown in the status indicator. + + /// Current status header shown in the status indicator. current_status_header: String, - // Previous status header to restore after a transient stream retry. + + /// Previous status header to restore after a transient stream retry. retry_status_header: Option, + + /// Current thread id once the session is configured. thread_id: Option, + + /// Parent thread id when the session was forked. forked_from: Option, + + /// Frame scheduler used to request redraws. frame_requester: FrameRequester, - // Whether to include the initial welcome banner on session configured + + /// Whether to include the initial welcome banner on session configured. show_welcome_banner: bool, - // When resuming an existing session (selected via resume picker), avoid an - // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. + + /// When resuming an existing session (selected via resume picker), avoid an + /// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, - // User messages queued while a turn is in progress + + /// User messages queued while a turn is in progress. + /// + /// These are drained one at a time when a turn completes to preserve ordering. queued_user_messages: VecDeque, - // Pending notification to show when unfocused on next Draw + + /// Pending notification to show when unfocused on next Draw. pending_notification: Option, + /// When `Some`, the user has pressed a quit shortcut and the second press /// must occur before `quit_shortcut_expires_at`. quit_shortcut_expires_at: Option, + /// Tracks which quit shortcut key was pressed first. /// /// We require the second press to match this key so `Ctrl+C` followed by /// `Ctrl+D` (or vice versa) doesn't quit accidentally. quit_shortcut_key: Option, - // Simple review mode flag; used to adjust layout and banners. + + /// Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, - // Snapshot of token usage to restore after review mode exits. + + /// Snapshot of token usage to restore after review mode exits. pre_review_token_info: Option>, - // Whether the next streamed assistant content should be preceded by a final message separator. - // - // This is set whenever we insert a visible history cell that conceptually belongs to a turn. - // The separator itself is only rendered if the turn recorded "work" activity (see - // `had_work_activity`). + + /// Whether the next streamed assistant content should be preceded by a final message separator. + /// + /// This is set whenever we insert a visible history cell that conceptually belongs to a turn. + /// The separator itself is only rendered if the turn recorded "work" activity (see + /// `had_work_activity`). needs_final_message_separator: bool, - // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). - // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. It is reset when the separator is emitted. + + /// Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). + /// + /// This gates rendering of the "Worked for …" separator so purely conversational turns don't + /// show an empty divider. It is reset when the separator is emitted. had_work_activity: bool, + /// Last rendered terminal width used for layout-dependent caching. last_rendered_width: std::cell::Cell>, - // Feedback sink for /feedback + + /// Feedback sink for `/feedback`. feedback: codex_feedback::CodexFeedback, - // Current session rollout path (if known) + + /// Current session rollout path (if known). current_rollout_path: Option, + + /// Current lifecycle state of the external editor workflow. external_editor_state: ExternalEditorState, } @@ -496,9 +705,11 @@ pub(crate) struct ActiveCellTranscriptKey { /// revision gives a cheap way to say "same active cell, but its transcript output is different /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. pub(crate) revision: u64, + /// Whether the active cell continues the prior stream, which affects /// spacing between transcript blocks. pub(crate) is_stream_continuation: bool, + /// Optional animation tick for time-dependent transcript output. /// /// When this changes, the overlay recomputes the cached tail even if the revision and width @@ -507,12 +718,17 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } +/// Bundles the user text and attached images for a single submission. struct UserMessage { + /// Raw message text as entered in the composer. text: String, + + /// File paths for image attachments included with the message. image_paths: Vec, } impl From for UserMessage { + /// Build a text-only user message from an owned string. fn from(text: String) -> Self { Self { text, @@ -522,6 +738,7 @@ impl From for UserMessage { } impl From<&str> for UserMessage { + /// Build a text-only user message from a borrowed string. fn from(text: &str) -> Self { Self { text: text.to_string(), @@ -530,6 +747,7 @@ impl From<&str> for UserMessage { } } +/// Combine text and images into an initial message, or return `None` if empty. fn create_initial_user_message(text: String, image_paths: Vec) -> Option { if text.is_empty() && image_paths.is_empty() { None @@ -548,6 +766,7 @@ impl ChatWidget { .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); } + /// Restore the current reasoning-derived status header, falling back to "Working". fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { self.set_status_header(header); @@ -556,6 +775,7 @@ impl ChatWidget { } } + /// Flush any pending unified-exec wait streak into history. fn flush_unified_exec_wait_streak(&mut self) { let Some(wait) = self.unified_exec_wait_streak.take() else { return; @@ -567,6 +787,7 @@ impl ChatWidget { self.restore_reasoning_status_header(); } + /// Finalize the active stream and insert its cell, if present. fn flush_answer_stream_with_separator(&mut self) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() @@ -589,13 +810,14 @@ impl ChatWidget { self.set_status(header, None); } + /// Restore the pre-retry status header after a transient retry completes. fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.retry_status_header.take() { self.set_status_header(header); } } - // --- Small event handlers --- + /// Handle the session configuration event and seed initial UI state. fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); @@ -618,6 +840,7 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } + // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { @@ -632,15 +855,18 @@ impl ChatWidget { } } + /// Update the bottom pane with a new skills list. fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } + /// Filter the skills list for the active cwd and update the bottom pane. fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { let skills = skills_for_cwd(&self.config.cwd, &response.skills); self.set_skills(Some(skills)); } + /// Open the feedback note overlay for the given category. pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -664,6 +890,7 @@ impl ChatWidget { self.request_redraw(); } + /// Open the feedback upload consent prompt for the given category. pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), @@ -674,6 +901,7 @@ impl ChatWidget { self.request_redraw(); } + /// Handle a finalized agent message event. fn on_agent_message(&mut self, message: String) { // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. @@ -685,10 +913,12 @@ impl ChatWidget { self.request_redraw(); } + /// Handle a partial agent message delta during streaming. fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } + /// Handle a reasoning delta by updating the status header and transcript buffers. fn on_agent_reasoning_delta(&mut self, delta: String) { // For reasoning deltas, do not stream to history. Accumulate the // current reasoning block and extract the first bold element @@ -705,11 +935,13 @@ impl ChatWidget { // Update the shimmer header to the extracted reasoning chunk header. self.set_status_header(header); } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. } self.request_redraw(); } + /// Finalize the current reasoning block and flush transcript-only history. fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); @@ -723,6 +955,7 @@ impl ChatWidget { self.request_redraw(); } + /// Break the reasoning block to preserve transcript boundaries between sections. fn on_reasoning_section_break(&mut self) { // Start a new reasoning block for header extraction and accumulate transcript. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); @@ -730,8 +963,7 @@ impl ChatWidget { self.reasoning_buffer.clear(); } - // Raw reasoning uses the same flow as summarized reasoning - + /// Mark the start of a new agent turn and reset per-turn UI state. fn on_task_started(&mut self) { self.agent_turn_running = true; self.bottom_pane.clear_quit_shortcut_hint(); @@ -746,10 +978,16 @@ impl ChatWidget { self.request_redraw(); } + /// Finalize UI state for the end of a turn and handle queued messages. + /// + /// This flushes any pending stream, clears per-turn transient state (exec tracking, headers, + /// running flags), then submits at most one queued user message and emits a completion + /// notification before checking for rate-limit prompts. fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); self.flush_unified_exec_wait_streak(); + // Mark task stopped and request redraw now that all content is in history. self.agent_turn_running = false; self.update_task_running_state(); @@ -762,6 +1000,7 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), @@ -770,6 +1009,7 @@ impl ChatWidget { self.maybe_show_pending_rate_limit_prompt(); } + /// Update token-usage state and refresh the context window display. pub(crate) fn set_token_info(&mut self, info: Option) { match info { Some(info) => self.apply_token_info(info), @@ -780,6 +1020,7 @@ impl ChatWidget { } } + /// Apply token usage metrics to UI state and cache them. fn apply_token_info(&mut self, info: TokenUsageInfo) { let percent = self.context_remaining_percent(&info); let used_tokens = self.context_used_tokens(&info, percent.is_some()); @@ -787,6 +1028,7 @@ impl ChatWidget { self.token_info = Some(info); } + /// Compute the remaining context percentage from the latest usage info. fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window.map(|window| { info.last_token_usage @@ -794,6 +1036,7 @@ impl ChatWidget { }) } + /// Compute the absolute number of tokens used, unless a percentage is already shown. fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { if percent_known { return None; @@ -802,6 +1045,7 @@ impl ChatWidget { Some(info.total_token_usage.tokens_in_context_window()) } + /// Restore the pre-review token usage snapshot after review mode exits. fn restore_pre_review_token_info(&mut self) { if let Some(saved) = self.pre_review_token_info.take() { match saved { @@ -814,6 +1058,10 @@ impl ChatWidget { } } + /// Apply a rate-limit snapshot and trigger any warning or prompt logic. + /// + /// This merges credits metadata, updates plan/rate-limit displays, emits any new warnings, and + /// arms the model-switch prompt if usage crosses the threshold. pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { if let Some(mut snapshot) = snapshot { if snapshot.credits.is_none() { @@ -881,6 +1129,7 @@ impl ChatWidget { self.rate_limit_snapshot = None; } } + /// Finalize any active exec as failed and stop/clear agent-turn UI state. /// /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup @@ -888,6 +1137,7 @@ impl ChatWidget { fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); + // Reset running state and clear streaming buffers. self.agent_turn_running = false; self.update_task_running_state(); @@ -899,6 +1149,7 @@ impl ChatWidget { self.maybe_show_pending_rate_limit_prompt(); } + /// Record an error event, finalize the turn, and continue queued inputs. fn on_error(&mut self, message: String) { self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); @@ -908,11 +1159,13 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + /// Record a warning event in the history transcript. fn on_warning(&mut self, message: impl Into) { self.add_to_history(history_cell::new_warning_event(message.into())); self.request_redraw(); } + /// Track MCP startup progress and update the status banner. fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &ev.status { @@ -959,6 +1212,7 @@ impl ChatWidget { self.request_redraw(); } + /// Finalize MCP startup status and surface any failures. fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { let mut parts = Vec::new(); if !ev.failed.is_empty() { @@ -1011,6 +1265,7 @@ impl ChatWidget { format!("{queued_text}\n{existing_text}") }; self.bottom_pane.set_composer_text(combined); + // Clear the queue and update the status indicator list. self.queued_user_messages.clear(); self.refresh_queued_user_messages(); @@ -1019,10 +1274,12 @@ impl ChatWidget { self.request_redraw(); } + /// Record a plan update event in the transcript. fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } + /// Handle an exec approval request, deferring if interrupts are queued. fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); @@ -1032,6 +1289,7 @@ impl ChatWidget { ); } + /// Handle a patch approval request, deferring if interrupts are queued. fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); @@ -1041,6 +1299,7 @@ impl ChatWidget { ); } + /// Handle an elicitation request, deferring if interrupts are queued. fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { let ev2 = ev.clone(); self.defer_or_handle( @@ -1049,6 +1308,7 @@ impl ChatWidget { ); } + /// Handle the start of an exec command and seed transcript grouping. fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -1061,6 +1321,7 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); } + /// Append exec output deltas to the active exec cell and redraw. fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { let Some(cell) = self .active_cell @@ -1076,6 +1337,7 @@ impl ChatWidget { } } + /// Handle background terminal interaction updates for unified exec. fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { self.flush_answer_stream_with_separator(); let command_display = self @@ -1124,6 +1386,7 @@ impl ChatWidget { } } + /// Record the start of a patch application in the transcript. fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, @@ -1131,6 +1394,7 @@ impl ChatWidget { )); } + /// Record a view-image tool call and refresh the UI. fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( @@ -1140,6 +1404,7 @@ impl ChatWidget { self.request_redraw(); } + /// Handle the end of a patch application, deferring if needed. fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { let ev2 = event.clone(); self.defer_or_handle( @@ -1148,6 +1413,7 @@ impl ChatWidget { ); } + /// Handle the end of an exec command, with unified-exec grouping logic. fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { if is_unified_exec_source(ev.source) { if let Some(process_id) = ev.process_id.as_deref() @@ -1167,6 +1433,7 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); } + /// Record a unified exec process when its startup begins. fn track_unified_exec_process_begin(&mut self, ev: &ExecCommandBeginEvent) { if ev.source != ExecCommandSource::UnifiedExecStartup { return; @@ -1188,6 +1455,7 @@ impl ChatWidget { self.sync_unified_exec_footer(); } + /// Remove a unified exec process summary when it completes. fn track_unified_exec_process_end(&mut self, ev: &ExecCommandEndEvent) { let key = ev.process_id.clone().unwrap_or(ev.call_id.to_string()); let before = self.unified_exec_processes.len(); @@ -1198,6 +1466,7 @@ impl ChatWidget { } } + /// Update the bottom pane with the current unified exec summaries. fn sync_unified_exec_footer(&mut self) { let processes = self .unified_exec_processes @@ -1207,6 +1476,7 @@ impl ChatWidget { self.bottom_pane.set_unified_exec_processes(processes); } + /// Clear all unified exec summaries and refresh the footer. fn clear_unified_exec_processes(&mut self) { if self.unified_exec_processes.is_empty() { return; @@ -1215,31 +1485,37 @@ impl ChatWidget { self.sync_unified_exec_footer(); } + /// Handle MCP tool call start events, deferring if needed. fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); } + /// Handle MCP tool call end events, deferring if needed. fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } + /// Prepare the UI for a web search tool call. fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); } + /// Record a web search tool call in the transcript. fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_web_search_call(ev.query)); } + /// Record a collab event cell and redraw. fn on_collab_event(&mut self, cell: PlainHistoryCell) { self.flush_answer_stream_with_separator(); self.add_to_history(cell); self.request_redraw(); } + /// Forward a history entry response to the bottom pane. fn on_get_history_entry_response( &mut self, event: codex_core::protocol::GetHistoryEntryResponseEvent, @@ -1253,20 +1529,24 @@ impl ChatWidget { .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); } + /// Immediately exit the TUI after shutdown completion. fn on_shutdown_complete(&mut self) { self.request_immediate_exit(); } + /// Log a turn diff event for debugging. fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); } + /// Record a deprecation notice in the transcript. fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { let DeprecationNoticeEvent { summary, details } = event; self.add_to_history(history_cell::new_deprecation_notice(summary, details)); self.request_redraw(); } + /// Surface a transient background status message. fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); self.bottom_pane.ensure_status_indicator(); @@ -1274,6 +1554,7 @@ impl ChatWidget { self.set_status_header(message); } + /// Surface undo-in-progress status and suppress interrupts. fn on_undo_started(&mut self, event: UndoStartedEvent) { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); @@ -1283,6 +1564,7 @@ impl ChatWidget { self.set_status_header(message); } + /// Surface the outcome of an undo operation in the transcript. fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); @@ -1300,6 +1582,7 @@ impl ChatWidget { } } + /// Update the status banner for a stream error without ending the turn. fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); @@ -1322,12 +1605,14 @@ impl ChatWidget { } } + /// Drain any queued interruptive events in a FIFO manner. fn flush_interrupt_queue(&mut self) { let mut mgr = std::mem::take(&mut self.interrupts); mgr.flush_all(self); self.interrupts = mgr; } + /// Either queue an interruptible event or handle it immediately. #[inline] fn defer_or_handle( &mut self, @@ -1344,15 +1629,21 @@ impl ChatWidget { } } + /// Finalize stream UI state when a streaming controller finishes. fn handle_stream_finished(&mut self) { if self.task_complete_pending { self.bottom_pane.hide_status_indicator(); self.task_complete_pending = false; } + // A completed stream indicates non-exec content was just inserted. self.flush_interrupt_queue(); } + /// Append streamed assistant text to the active stream controller. + /// + /// This flushes any active exec cell, inserts a separator if needed, initializes a streaming + /// controller on the first delta, then pushes new text and schedules commit animation/redraws. #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. @@ -1385,6 +1676,7 @@ impl ChatWidget { self.request_redraw(); } + /// Apply an exec completion immediately and update transcript state. pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); if self.suppressed_exec_calls.remove(&ev.call_id) { @@ -1440,10 +1732,12 @@ impl ChatWidget { self.request_redraw(); } } + // Mark that actual work was done (command executed) self.had_work_activity = true; } + /// Apply a patch completion immediately and record failures in the transcript. pub(crate) fn handle_patch_apply_end_now( &mut self, event: codex_core::protocol::PatchApplyEndEvent, @@ -1453,10 +1747,12 @@ impl ChatWidget { if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + // Mark that actual work was done (patch applied) self.had_work_activity = true; } + /// Show an exec approval request immediately in the UI. pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) @@ -1474,6 +1770,7 @@ impl ChatWidget { self.request_redraw(); } + /// Show an apply-patch approval request immediately in the UI. pub(crate) fn handle_apply_patch_approval_now( &mut self, id: String, @@ -1496,6 +1793,7 @@ impl ChatWidget { }); } + /// Show an MCP elicitation request immediately in the UI. pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { self.flush_answer_stream_with_separator(); @@ -1513,6 +1811,7 @@ impl ChatWidget { self.request_redraw(); } + /// Handle an exec begin event immediately, updating active exec grouping. pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1576,6 +1875,7 @@ impl ChatWidget { self.request_redraw(); } + /// Start an MCP tool-call cell immediately and refresh the UI. pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); @@ -1587,6 +1887,8 @@ impl ChatWidget { self.bump_active_cell_revision(); self.request_redraw(); } + + /// Apply an MCP tool-call completion event immediately. pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); @@ -1620,10 +1922,12 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + // Mark that actual work was done (MCP tool call) self.had_work_activity = true; } + /// Create a new chat widget and spawn a fresh agent thread. pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { let ChatWidgetInit { config, @@ -1825,6 +2129,11 @@ impl ChatWidget { widget } + /// Handle a key event from the terminal and route it to the correct UI surface. + /// + /// The flow is: (1) intercept global shortcuts (quit, paste image), (2) reset quit-shortcut + /// state on regular input, then (3) delegate to the bottom pane and translate its + /// `InputResult` into queued or submitted actions. pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1936,37 +2245,49 @@ impl ChatWidget { } } + /// Attach an image to the composer for the next submission. pub(crate) fn attach_image(&mut self, path: PathBuf) { tracing::info!("attach_image path={path:?}"); self.bottom_pane.attach_image(path); self.request_redraw(); } + /// Return the composer text, including any pending queued submission. pub(crate) fn composer_text_with_pending(&self) -> String { self.bottom_pane.composer_text_with_pending() } + /// Apply an external-editor edit and refresh the UI. pub(crate) fn apply_external_edit(&mut self, text: String) { self.bottom_pane.apply_external_edit(text); self.request_redraw(); } + /// Return the current external editor state. pub(crate) fn external_editor_state(&self) -> ExternalEditorState { self.external_editor_state } + /// Update the external editor state. pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) { self.external_editor_state = state; } + /// Override the footer hint text while the override is active. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { self.bottom_pane.set_footer_hint_override(items); } + /// Return true if the external editor can be launched in the current state. pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } + /// Dispatch a slash command without additional arguments. + /// + /// This first enforces task-running guards, then performs the command-specific behavior such + /// as opening popups, submitting ops, or emitting status output. Commands may also enqueue + /// history cells or app events to keep the transcript and overlays in sync with the action. fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -1985,6 +2306,7 @@ impl ChatWidget { self.request_redraw(); return; } + // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); @@ -2009,6 +2331,8 @@ impl ChatWidget { self.add_info_message(message, None); return; } + + /// Prompt content sent as the initial message for `/init`. const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); } @@ -2061,6 +2385,7 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] { + // Not supported; on non-Windows this command should never be reachable. }; } @@ -2079,6 +2404,7 @@ impl ChatWidget { } self.request_quit_without_confirmation(); } + // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); // } @@ -2133,6 +2459,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::CodexEvent(Event { id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { // call_id: "1".to_string(), // command: vec!["git".into(), "apply".into()], @@ -2165,6 +2492,10 @@ impl ChatWidget { } } + /// Dispatch a slash command that accepts additional arguments. + /// + /// Arguments are trimmed before matching; only commands with explicit argument behavior are + /// handled here and all others fall back to the no-arg dispatcher. fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -2192,11 +2523,15 @@ impl ChatWidget { } } + /// Pass pasted text through to the composer, preserving paste-burst behavior. pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } - // Returns true if caller should skip rendering this frame (a future frame is scheduled). + /// Handle a paste-burst tick; returns true when the frame should be skipped. + /// + /// This allows the composer to batch large paste bursts by scheduling the next tick and + /// skipping redundant renders between flushes. pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { // A paste just flushed; request an immediate redraw and skip this frame. @@ -2214,6 +2549,7 @@ impl ChatWidget { } } + /// Flush the active in-flight cell into history, if present. fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { self.needs_final_message_separator = true; @@ -2221,10 +2557,12 @@ impl ChatWidget { } } + /// Add a history cell to the transcript, boxing it for storage. pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.add_boxed_history(Box::new(cell)); } + /// Add a boxed history cell, managing grouping and placeholder headers. fn add_boxed_history(&mut self, cell: Box) { // Keep the placeholder session header as the active cell until real session info arrives, // so we can merge headers instead of committing a duplicate box to history. @@ -2242,6 +2580,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } + /// Queue a user message if the session cannot accept input immediately. fn queue_user_message(&mut self, user_message: UserMessage) { if !self.is_session_configured() || self.bottom_pane.is_task_running() @@ -2254,6 +2593,11 @@ impl ChatWidget { } } + /// Submit a user message as inputs or a local command. + /// + /// This normalizes local `!` commands, builds the `UserInput` sequence (images, text, skill + /// mentions), submits it to core, then mirrors the user text into history and resets the + /// message-separator state. fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { @@ -2338,11 +2682,16 @@ impl ChatWidget { if matches!(msg, EventMsg::SessionConfigured(_)) { continue; } + // `id: None` indicates a synthetic/fake id coming from replay. self.dispatch_event_msg(None, msg, true); } } + /// Dispatch an incoming Codex protocol event into UI handlers. + /// + /// This unpacks the id and routes the message through `dispatch_event_msg`, which applies + /// replay vs live event handling rules. pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; self.dispatch_event_msg(Some(id), msg, false); @@ -2353,6 +2702,11 @@ impl ChatWidget { /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. + /// + /// The handler first restores any retry header, then selectively logs traces, and finally + /// routes each event variant to its dedicated handler while honoring replay-only rules. + /// It is also responsible for preserving ordering when streaming output is mutating history + /// cells by deferring interruptive UI changes until the stream settles. fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); if !is_stream_error { @@ -2483,11 +2837,13 @@ impl ChatWidget { } } + /// Enter review mode and seed the transcript with a review banner. fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { // Enter review mode and emit a concise banner if self.pre_review_token_info.is_none() { self.pre_review_token_info = Some(self.token_info.clone()); } + // Avoid toggling running state for replayed history events on resume. if !from_replay && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(true); @@ -2501,6 +2857,7 @@ impl ChatWidget { self.request_redraw(); } + /// Exit review mode and display any review output summary. fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { // Leave review mode; if output is present, flush pending stream + show results. if let Some(output) = review.review_output { @@ -2524,11 +2881,13 @@ impl ChatWidget { .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); } } + // Final message is rendered as part of the AgentMessage. } self.is_review_mode = false; self.restore_pre_review_token_info(); + // Append a finishing banner at the end of this turn. self.add_to_history(history_cell::new_review_status_line( "<< Code review finished >>".to_string(), @@ -2536,6 +2895,7 @@ impl ChatWidget { self.request_redraw(); } + /// Record a user message event that arrives via replayed history. fn on_user_message_event(&mut self, event: UserMessageEvent) { let message = event.message.trim(); if !message.is_empty() { @@ -2560,16 +2920,19 @@ impl ChatWidget { .send(AppEvent::Exit(ExitMode::ShutdownFirst)); } + /// Schedule a redraw on the next frame tick. fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } + /// Bump the active-cell revision to invalidate transcript overlay caching. fn bump_active_cell_revision(&mut self) { // Wrapping avoids overflow; wraparound would require 2^64 bumps and at // worst causes a one-time cache-key collision. self.active_cell_revision = self.active_cell_revision.wrapping_add(1); } + /// Queue a notification if allowed by user settings. fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; @@ -2578,6 +2941,7 @@ impl ChatWidget { self.request_redraw(); } + /// Emit any pending notification to the OS-level notifier. 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()); @@ -2597,7 +2961,7 @@ impl ChatWidget { } } - // If idle and there are queued inputs, submit exactly one to start the next turn. + /// If idle, submit at most one queued input to start the next turn. fn maybe_send_next_queued_input(&mut self) { if self.bottom_pane.is_task_running() { return; @@ -2605,6 +2969,7 @@ impl ChatWidget { if let Some(user_message) = self.queued_user_messages.pop_front() { self.submit_user_message(user_message); } + // Update the list to reflect the remaining queued messages (if any). self.refresh_queued_user_messages(); } @@ -2619,14 +2984,17 @@ impl ChatWidget { self.bottom_pane.set_queued_user_messages(messages); } + /// Request a redraw to show an in-progress diff fetch. pub(crate) fn add_diff_in_progress(&mut self) { self.request_redraw(); } + /// Request a redraw after diff results are ready. pub(crate) fn on_diff_complete(&mut self) { self.request_redraw(); } + /// Emit a `/status` snapshot into the transcript. pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let token_info = self.token_info.as_ref(); @@ -2647,6 +3015,7 @@ impl ChatWidget { )); } + /// Emit a `/ps` snapshot into the transcript. pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes @@ -2656,12 +3025,14 @@ impl ChatWidget { self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } + /// Abort the background rate-limit poller if it is running. fn stop_rate_limit_poller(&mut self) { if let Some(handle) = self.rate_limit_poller.take() { handle.abort(); } } + /// Start a background task to prefetch rate-limit snapshots. fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); @@ -2690,6 +3061,7 @@ impl ChatWidget { self.rate_limit_poller = Some(handle); } + /// Return the lower-cost model preset, if available. fn lower_cost_preset(&self) -> Option { let models = self.models_manager.try_list_models(&self.config).ok()?; models @@ -2698,6 +3070,7 @@ impl ChatWidget { .cloned() } + /// Return true when the user has hidden the rate-limit switch prompt. fn rate_limit_switch_prompt_hidden(&self) -> bool { self.config .notices @@ -2705,6 +3078,7 @@ impl ChatWidget { .unwrap_or(false) } + /// Show the rate-limit switch prompt if a pending threshold was reached. fn maybe_show_pending_rate_limit_prompt(&mut self) { if self.rate_limit_switch_prompt_hidden() { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; @@ -2724,6 +3098,7 @@ impl ChatWidget { } } + /// Open the rate-limit switch prompt with the suggested preset. fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { let switch_model = preset.model.to_string(); let display_name = preset.display_name.to_string(); @@ -2819,6 +3194,7 @@ impl ChatWidget { self.open_model_popup_with_presets(presets); } + /// Build the shared header block for model selection popups. fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { let title = title.to_string(); let subtitle = subtitle.to_string(); @@ -2831,6 +3207,7 @@ impl ChatWidget { Box::new(header) } + /// Return a warning line when model selection is routed via a custom base URL. fn model_menu_warning_line(&self) -> Option> { let base_url = self.custom_openai_base_url()?; let warning = format!( @@ -2839,6 +3216,7 @@ impl ChatWidget { Some(Line::from(warning.red())) } + /// Return the configured OpenAI base URL if it differs from the default. fn custom_openai_base_url(&self) -> Option { if !self.config.model_provider.is_openai() { return None; @@ -2858,6 +3236,7 @@ impl ChatWidget { Some(trimmed.to_string()) } + /// Open the model picker using the provided presets list. pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { let presets: Vec = presets .into_iter() @@ -2939,10 +3318,12 @@ impl ChatWidget { }); } + /// Return true if the model slug represents an auto model. fn is_auto_model(model: &str) -> bool { model.starts_with("codex-auto-") } + /// Return the ordering index for auto model options. fn auto_model_order(model: &str) -> usize { match model { "codex-auto-fast" => 0, @@ -2952,6 +3333,7 @@ impl ChatWidget { } } + /// Open the full model list, including per-model reasoning effort. pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { if presets.is_empty() { self.add_info_message( @@ -2997,6 +3379,10 @@ impl ChatWidget { }); } + /// Build selection actions that apply a model and optional reasoning effort. + /// + /// The actions update both the live session state and the persisted model selection so the + /// UI and future sessions stay consistent. fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3029,6 +3415,9 @@ impl ChatWidget { } /// Open a popup to choose the reasoning effort (stage 2) for the given model. + /// + /// This builds the supported effort options, applies warnings for high-effort choices, and + /// either auto-selects the only option or presents a selection list. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; @@ -3054,8 +3443,12 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); + /// Single reasoning-effort option shown in the stage-two picker. struct EffortChoice { + /// Optional stored value to persist for this selection. stored: Option, + + /// Label to show in the UI list (always concrete). display: ReasoningEffortConfig, } let mut choices: Vec = Vec::new(); @@ -3164,6 +3557,7 @@ impl ChatWidget { }); } + /// Return the display label for a reasoning effort value. fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { match effort { ReasoningEffortConfig::None => "None", @@ -3175,6 +3569,7 @@ impl ChatWidget { } } + /// Apply a model and reasoning effort selection through app events. fn apply_model_and_effort(&self, model: String, effort: Option) { self.app_event_tx .send(AppEvent::CodexOp(Op::OverrideTurnContext { @@ -3321,6 +3716,7 @@ impl ChatWidget { }); } + /// Open the experimental features toggle list. pub(crate) fn open_experimental_popup(&mut self) { let features: Vec = FEATURES .iter() @@ -3340,6 +3736,7 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + /// Build approval preset actions that set approval and sandbox policies. fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, @@ -3360,6 +3757,7 @@ impl ChatWidget { })] } + /// Return true if the preset matches the currently active policies. fn preset_matches_current( current_approval: AskForApproval, current_sandbox: &SandboxPolicy, @@ -3382,6 +3780,7 @@ impl ChatWidget { ) } + /// Return world-writable warning details or `None` if no warning is needed. #[cfg(target_os = "windows")] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { if self @@ -3406,12 +3805,14 @@ impl ChatWidget { } } + /// Return `None` on non-Windows platforms where the warning does not apply. #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { None } + /// Show a confirmation prompt before enabling full-access approvals. pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { let approval = preset.approval; let sandbox = preset.sandbox; @@ -3476,6 +3877,7 @@ impl ChatWidget { }); } + /// Show a warning before enabling a world-writable sandbox on Windows. #[cfg(target_os = "windows")] pub(crate) fn open_world_writable_warning_confirmation( &mut self, @@ -3532,6 +3934,7 @@ impl ChatWidget { // Build actions ensuring acknowledgement happens before applying the new sandbox policy, // so downstream policy-change hooks don't re-trigger the warning. let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), // to avoid duplicate warnings from the ensuing policy change. if preset.is_some() { @@ -3577,6 +3980,7 @@ impl ChatWidget { }); } + /// No-op on non-Windows platforms where the warning UI is unavailable. #[cfg(not(target_os = "windows"))] pub(crate) fn open_world_writable_warning_confirmation( &mut self, @@ -3587,6 +3991,7 @@ impl ChatWidget { ) { } + /// Prompt the user to set up the Windows sandbox for agent mode. #[cfg(target_os = "windows")] pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { use ratatui_macros::line; @@ -3705,9 +4110,11 @@ impl ChatWidget { }); } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + /// Prompt the user to fall back to the non-elevated sandbox after failure. #[cfg(target_os = "windows")] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, @@ -3801,6 +4208,7 @@ impl ChatWidget { }); } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] pub(crate) fn open_windows_sandbox_fallback_prompt( &mut self, @@ -3809,6 +4217,7 @@ impl ChatWidget { ) { } + /// Prompt the user to set up Windows sandbox if auto mode was downgraded. #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { if self.config.forced_auto_mode_downgraded_on_windows @@ -3821,9 +4230,11 @@ impl ChatWidget { } } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + /// Disable composer input and show sandbox-setup status while installing. #[cfg(target_os = "windows")] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { // While elevated sandbox setup runs, prevent typing so the user doesn't @@ -3838,10 +4249,12 @@ impl ChatWidget { self.request_redraw(); } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} + /// Clear the sandbox-setup status and re-enable input. #[cfg(target_os = "windows")] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { self.bottom_pane.set_composer_input_enabled(true, None); @@ -3849,14 +4262,17 @@ impl ChatWidget { self.request_redraw(); } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} + /// Clear the forced auto-mode downgrade flag after successful setup. #[cfg(target_os = "windows")] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { self.config.forced_auto_mode_downgraded_on_windows = false; } + /// No-op on non-Windows platforms where the Windows sandbox UI is unavailable. #[cfg(not(target_os = "windows"))] #[allow(dead_code)] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} @@ -3884,6 +4300,7 @@ impl ChatWidget { Ok(()) } + /// Toggle a feature flag and update dependent UI state. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { if enabled { @@ -3896,14 +4313,17 @@ impl ChatWidget { } } + /// Persist the full-access warning acknowledgement in config state. pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_full_access_warning = Some(acknowledged); } + /// Persist the world-writable warning acknowledgement in config state. pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_world_writable_warning = Some(acknowledged); } + /// Update the rate-limit model nudge suppression flag. pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { self.config.notices.hide_rate_limit_model_nudge = Some(hidden); if hidden { @@ -3911,6 +4331,7 @@ impl ChatWidget { } } + /// Return true when the world-writable warning is suppressed. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn world_writable_warning_hidden(&self) -> bool { self.config @@ -3930,10 +4351,12 @@ impl ChatWidget { self.model = Some(model.to_string()); } + /// Return the configured model slug, if known. fn current_model(&self) -> Option<&str> { self.model.as_deref() } + /// Return the model label to show in the UI. fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } @@ -3978,21 +4401,25 @@ impl ChatWidget { } } + /// Add an informational message cell to the transcript. pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { self.add_to_history(history_cell::new_info_event(message, hint)); self.request_redraw(); } + /// Add raw history lines directly to the transcript. pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); self.request_redraw(); } + /// Add an error message cell to the transcript. pub(crate) fn add_error_message(&mut self, message: String) { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); } + /// Add MCP tool output to the transcript or request it from core. pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); @@ -4105,11 +4532,12 @@ impl ChatWidget { self.bottom_pane.show_quit_shortcut_hint(key); } - // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. + /// Return true when Ctrl+C should interrupt rather than quit. fn is_cancellable_work_active(&self) -> bool { self.bottom_pane.is_task_running() || self.is_review_mode } + /// Return true if the composer is empty and has no draft content. pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.composer_is_empty() } @@ -4121,6 +4549,7 @@ impl ChatWidget { self.bottom_pane.is_normal_backtrack_mode() } + /// Insert text into the composer at the current cursor position. pub(crate) fn insert_str(&mut self, text: &str) { self.bottom_pane.insert_str(text); } @@ -4130,13 +4559,16 @@ impl ChatWidget { self.bottom_pane.set_composer_text(text); } + /// Show the Esc-backtrack hint in the footer. pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } + /// Clear the Esc-backtrack hint in the footer. pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&mut self, op: Op) { // Record outbound operation for session replay fidelity. @@ -4149,6 +4581,7 @@ impl ChatWidget { } } + /// Render the list of MCP tools into the transcript. fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, @@ -4159,17 +4592,21 @@ impl ChatWidget { )); } + /// Update the bottom pane with the latest custom prompts list. fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { let len = ev.custom_prompts.len(); debug!("received {len} custom prompts"); + // Forward to bottom pane so the slash popup can show them now. self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + /// Update the skills list shown in the bottom pane. fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { self.set_skills_from_response(&ev); } + /// Open the review mode selection popup. pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -4230,6 +4667,7 @@ impl ChatWidget { }); } + /// Show a picker for selecting a base branch for review. 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) @@ -4267,6 +4705,7 @@ impl ChatWidget { }); } + /// Show a picker for selecting a specific commit to review. pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { let commits = codex_core::git_info::recent_commits(cwd, 100).await; @@ -4305,6 +4744,7 @@ impl ChatWidget { }); } + /// Open the custom review prompt editor. pub(crate) fn show_review_custom_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( @@ -4329,6 +4769,7 @@ impl ChatWidget { self.bottom_pane.show_view(Box::new(view)); } + /// Return the latest total token usage for the session. pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() @@ -4336,14 +4777,17 @@ impl ChatWidget { .unwrap_or_default() } + /// Return the current thread id, if configured. pub(crate) fn thread_id(&self) -> Option { self.thread_id } + /// Return true once session configuration has completed. fn is_session_configured(&self) -> bool { self.thread_id.is_some() } + /// Return the current rollout path, if one has been assigned. pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } @@ -4385,10 +4829,12 @@ impl ChatWidget { &self.config } + /// Clear any cached token usage information from the UI state. pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; } + /// Build a renderable tree combining the transcript and bottom pane. fn as_renderable(&self) -> RenderableItem<'_> { let active_cell_renderable = match &self.active_cell { Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), @@ -4405,34 +4851,47 @@ impl ChatWidget { } impl Drop for ChatWidget { + /// Abort background tasks when the widget is dropped. fn drop(&mut self) { self.stop_rate_limit_poller(); } } impl Renderable for ChatWidget { + /// Render the transcript and bottom pane into the provided buffer. fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); self.last_rendered_width.set(Some(area.width as usize)); } + /// Return the desired height for the current layout given the width. fn desired_height(&self, width: u16) -> u16 { self.as_renderable().desired_height(width) } + /// Return the cursor position in the composed UI, if any. fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } } +/// Notification events shown outside the TUI (system notifications). enum Notification { + /// Agent turn completion, with the last response text. AgentTurnComplete { response: String }, + + /// Exec approval request prompt with a command summary. ExecApprovalRequested { command: String }, + + /// Patch approval request prompt with target paths. EditApprovalRequested { cwd: PathBuf, changes: Vec }, + + /// MCP elicitation request prompt with server name. ElicitationRequested { server_name: String }, } impl Notification { + /// Render the notification for display in OS-level notices. fn display(&self) -> String { match self { Notification::AgentTurnComplete { response } => { @@ -4459,6 +4918,7 @@ impl Notification { } } + /// Return the settings key used to filter this notification. fn type_name(&self) -> &str { match self { Notification::AgentTurnComplete { .. } => "agent-turn-complete", @@ -4468,6 +4928,7 @@ impl Notification { } } + /// Return true if this notification type is allowed by user settings. fn allowed_for(&self, settings: &Notifications) -> bool { match settings { Notifications::Enabled(enabled) => *enabled, @@ -4475,6 +4936,7 @@ impl Notification { } } + /// Build a single-line preview of an agent response for notifications. fn agent_turn_preview(response: &str) -> Option { let mut normalized = String::new(); for part in response.split_whitespace() { @@ -4492,8 +4954,10 @@ impl Notification { } } +/// Maximum grapheme count for agent completion notifications. const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; +/// Placeholder prompts shown in the composer before the first input. const PLACEHOLDERS: [&str; 8] = [ "Explain this codebase", "Summarize recent commits", @@ -4505,8 +4969,9 @@ const PLACEHOLDERS: [&str; 8] = [ "Use /skills to list available skills", ]; -// Extract the first bold (Markdown) element in the form **...** from `s`. -// Returns the inner text if found; otherwise `None`. +/// Extract the first bold Markdown element (`**...**`) from `s`. +/// +/// Returns the inner text if found, otherwise `None`. fn extract_first_bold(s: &str) -> Option { let bytes = s.as_bytes(); let mut i = 0usize; @@ -4527,6 +4992,7 @@ fn extract_first_bold(s: &str) -> Option { } j += 1; } + // No closing; stop searching (wait for more deltas) return None; } @@ -4535,6 +5001,7 @@ fn extract_first_bold(s: &str) -> Option { None } +/// Fetch the latest rate-limit snapshot for the authenticated session. async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { match BackendClient::from_auth(base_url, &auth) { Ok(client) => match client.get_rate_limits().await { @@ -4551,6 +5018,7 @@ async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option Vec { skills_entries .iter() @@ -4619,6 +5088,7 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec Vec { let mut seen: HashSet = HashSet::new(); let mut matches: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index b24233c2af..a2a85385dd 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -1,3 +1,21 @@ +//! Agent bootstrap and event forwarding glue for the TUI chat widget. +//! +//! This module bridges the UI event loop with the async Codex runtime. It +//! spawns background tasks that start or attach to a [`CodexThread`], forwards +//! UI-originated [`Op`] messages into the thread, and relays incoming protocol +//! [`Event`]s back to the UI via [`AppEvent`]. It owns no long-lived state of +//! its own; instead it wires together channels and tasks so the UI remains +//! responsive while the agent runs. +//! +//! Correctness relies on forwarding the initial `SessionConfigured` event +//! before normal event streaming begins so the UI can render session metadata +//! consistently, and on reporting initialization failures as fatal exits so the +//! UI can shut down cleanly. Event forwarding is best-effort and unbuffered at +//! the protocol level; the UI is responsible for ordering and deduping if it +//! aggregates multiple sources. +//! +//! The op channel is intentionally unbounded to avoid blocking the UI thread; +//! callers must drop the sender to shut the submission task down. use std::sync::Arc; use codex_core::CodexThread; @@ -13,8 +31,15 @@ use tokio::sync::mpsc::unbounded_channel; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. +/// Spawns the agent bootstrapper and op-forwarding loop for a new thread. +/// +/// The returned sender is used by the UI to submit [`Op`]s. A background task +/// initializes the thread via [`ThreadManager::start_thread`], forwards the +/// resulting `SessionConfigured` event, and then streams events to the UI. A +/// second task drains the op channel and submits each op to the thread. +/// +/// The op channel is unbounded and the submission loop runs until the channel is +/// closed, so callers must drop the sender when the UI is shutting down. pub(crate) fn spawn_agent( config: Config, app_event_tx: AppEventSender, @@ -37,6 +62,7 @@ pub(crate) fn spawn_agent( id: "".to_string(), msg: EventMsg::Error(err.to_error_event(None)), })); + // Surface the error to the UI and request a clean shutdown. app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); tracing::error!("failed to initialize codex: {err}"); return; @@ -69,9 +95,14 @@ pub(crate) fn spawn_agent( codex_op_tx } -/// Spawn agent loops for an existing thread (e.g., a forked thread). -/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent -/// events and accepts Ops for submission. +/// Spawns agent loops for an existing thread (for example, a forked thread). +/// +/// The provided `SessionConfiguredEvent` is forwarded immediately so the UI can +/// render session metadata, then subsequent protocol events are streamed while +/// the op channel is drained and submitted to the thread. +/// +/// This mirrors [`spawn_agent`] but skips thread creation; it assumes the +/// caller already owns a live [`CodexThread`]. pub(crate) fn spawn_agent_from_existing( thread: std::sync::Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea5..64b60330bd 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -1,3 +1,17 @@ +//! Queues interrupt-style events until the chat widget is ready to handle them. +//! +//! The chat widget receives protocol events that can interrupt normal rendering (approval +//! requests, tool call boundaries, execution begin/end notifications). This module buffers those +//! events in arrival order and provides a single flush point so the UI can drain them once the +//! widget is in a safe state to update. +//! +//! The manager owns only the queue; it does not interpret events beyond routing them to the +//! matching `ChatWidget` handler. Callers decide when to enqueue (typically during streaming) and +//! when to flush (typically after rendering or state transitions). +//! +//! Ordering is preserved with a FIFO queue, and flushing always routes to the corresponding +//! immediate handler on [`ChatWidget`]. + use std::collections::VecDeque; use codex_core::protocol::ApplyPatchApprovalRequestEvent; @@ -11,39 +25,64 @@ use codex_protocol::approvals::ElicitationRequestEvent; use super::ChatWidget; +/// A deferred interrupt event awaiting delivery to [`ChatWidget`]. #[derive(Debug)] pub(crate) enum QueuedInterrupt { + /// Request for a user decision on an exec command, keyed by approval id. ExecApproval(String, ExecApprovalRequestEvent), + + /// Request for a user decision on a patch application, keyed by approval id. ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), + + /// Out-of-band elicitation prompt to show in the UI. Elicitation(ElicitationRequestEvent), + + /// Execution begin boundary, used to create or update history cells. ExecBegin(ExecCommandBeginEvent), + + /// Execution end boundary, used to finalize history cells. ExecEnd(ExecCommandEndEvent), + + /// MCP tool call begin boundary for history cell tracking. McpBegin(McpToolCallBeginEvent), + + /// MCP tool call end boundary for history cell tracking. McpEnd(McpToolCallEndEvent), + + /// Patch application completion notification. + /// + /// This signals that a patch has finished applying and any related UI can + /// transition from "working" to a terminal state. PatchEnd(PatchApplyEndEvent), } +/// FIFO buffer for interrupt events that must be applied in order. #[derive(Default)] pub(crate) struct InterruptManager { + /// Pending interrupt events in arrival order. queue: VecDeque, } impl InterruptManager { + /// Create an empty interrupt queue. pub(crate) fn new() -> Self { Self { queue: VecDeque::new(), } } + /// Return true if there are no queued interrupts to process. #[inline] pub(crate) fn is_empty(&self) -> bool { self.queue.is_empty() } + /// Queue an exec approval request until the UI can render it. pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev)); } + /// Queue a patch approval request until the UI can render it. pub(crate) fn push_apply_patch_approval( &mut self, id: String, @@ -53,30 +92,41 @@ impl InterruptManager { .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); } + /// Queue an elicitation request until the UI can render it. pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + /// Queue an exec-begin event until the UI can update history cells. pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } + /// Queue an exec-end event until the UI can update history cells. pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); } + /// Queue an MCP tool call begin event until the UI can update history cells. pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { self.queue.push_back(QueuedInterrupt::McpBegin(ev)); } + /// Queue an MCP tool call end event until the UI can update history cells. pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { self.queue.push_back(QueuedInterrupt::McpEnd(ev)); } + /// Queue a patch end event until the UI can update history cells. pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); } + /// Drain all queued interrupts into the `ChatWidget` in FIFO order. + /// + /// This hands each buffered event to the corresponding immediate handler, + /// preserving arrival ordering so the UI reflects the same causal sequence + /// as the backend stream. pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { while let Some(q) = self.queue.pop_front() { match q { diff --git a/codex-rs/tui/src/chatwidget/session_header.rs b/codex-rs/tui/src/chatwidget/session_header.rs index 32e31b6682..e93ed3231c 100644 --- a/codex-rs/tui/src/chatwidget/session_header.rs +++ b/codex-rs/tui/src/chatwidget/session_header.rs @@ -1,13 +1,21 @@ +//! State for rendering the chat session header. +//! +//! The header currently only tracks the model label shown at the top of the chat view. The +//! state is intentionally small so it can be updated independently from the transcript layout. + +/// Stores the model name shown in the chat header. pub(crate) struct SessionHeader { + /// Cached model label to avoid redundant allocations on repeated updates. model: String, } impl SessionHeader { + /// Create a header initialized with the current model label. pub(crate) fn new(model: String) -> Self { Self { model } } - /// Updates the header's model text. + /// Updates the header's model label when it changes. pub(crate) fn set_model(&mut self, model: &str) { if self.model != model { self.model = model.to_string(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 19d10ad51d..8e81287225 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3,6 +3,11 @@ //! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and //! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header //! changes show up as stable, reviewable diffs. +//! +//! Most tests build a widget with in-memory channels, inject protocol events or key presses, and +//! then drain `AppEvent::InsertHistoryCell` output for inspection. VT100-backed snapshots capture +//! end-to-end layout, while targeted helpers (`render_bottom_popup`, `active_blob`) focus on modal +//! and active-cell rendering without requiring a full application loop. use super::*; use crate::app_event::AppEvent; @@ -81,16 +86,19 @@ use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +/// Sets the Windows sandbox feature flag for tests that need to toggle behavior. #[cfg(target_os = "windows")] fn set_windows_sandbox_enabled(enabled: bool) { codex_core::set_windows_sandbox_enabled(enabled); } +/// Sets the elevated sandbox feature flag for tests that exercise degraded flows. #[cfg(target_os = "windows")] fn set_windows_elevated_sandbox_enabled(enabled: bool) { codex_core::set_windows_elevated_sandbox_enabled(enabled); } +/// Builds a baseline config using temp directories to avoid host dependencies. async fn test_config() -> Config { // Use base defaults to avoid depending on host state. let codex_home = std::env::temp_dir(); @@ -101,6 +109,7 @@ async fn test_config() -> Config { .expect("config") } +/// Constructs a constraint error for invalid values in test helpers. fn invalid_value(candidate: impl Into, allowed: impl Into) -> ConstraintError { ConstraintError::InvalidValue { field_name: "", @@ -110,6 +119,7 @@ fn invalid_value(candidate: impl Into, allowed: impl Into) -> Co } } +/// Creates a rate-limit snapshot with a single primary window. fn snapshot(percent: f64) -> RateLimitSnapshot { RateLimitSnapshot { primary: Some(RateLimitWindow { @@ -123,6 +133,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { } } +/// Replays `SessionConfigured` initial messages into the history stream. #[tokio::test] async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -181,7 +192,7 @@ async fn resumed_initial_messages_render_history() { ); } -/// Entering review mode uses the hint provided by the review request. +/// Uses the review request hint when rendering the "review started" banner. #[tokio::test] async fn entered_review_mode_uses_request_hint() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -202,7 +213,7 @@ async fn entered_review_mode_uses_request_hint() { assert!(chat.is_review_mode); } -/// Entering review mode renders the current changes banner when requested. +/// Falls back to the default "current changes" review banner when no hint is provided. #[tokio::test] async fn entered_review_mode_defaults_to_current_changes_banner() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -221,7 +232,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { assert!(chat.is_review_mode); } -/// Exiting review restores the pre-review context window indicator. +/// Restores the pre-review context window indicator after exiting review mode. #[tokio::test] async fn review_restores_context_window_indicator() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -270,7 +281,7 @@ async fn review_restores_context_window_indicator() { assert!(!chat.is_review_mode); } -/// Receiving a TokenCount event without usage clears the context indicator. +/// Clears the context window indicator when token usage is missing. #[tokio::test] async fn token_count_none_resets_context_indicator() { let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await; @@ -297,6 +308,7 @@ async fn token_count_none_resets_context_indicator() { assert_eq!(chat.bottom_pane.context_window_percent(), None); } +/// Falls back to used-token counts when the model's context window is unknown. #[tokio::test] async fn context_indicator_shows_used_tokens_when_window_unknown() { let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await; @@ -332,6 +344,7 @@ async fn context_indicator_shows_used_tokens_when_window_unknown() { ); } +/// Ensures helper wiring is present without exercising external system APIs. #[cfg_attr( target_os = "macos", ignore = "system configuration APIs are blocked under macOS seatbelt" @@ -365,7 +378,7 @@ async fn helpers_are_available_and_do_not_panic() { let _ = &mut w; } -// --- Helpers for tests that need direct construction and event draining --- +/// Builds a chat widget with test-only channels and an optional model override. async fn make_chatwidget_manual( model_override: Option<&str>, ) -> ( @@ -449,6 +462,7 @@ async fn make_chatwidget_manual( (widget, rx, op_rx) } +/// Switches the widget to a ChatGPT auth flow and refreshes the models manager. fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); @@ -458,6 +472,7 @@ fn set_chatgpt_auth(chat: &mut ChatWidget) { )); } +/// Returns a test widget plus its event sender and receivers for assertions. pub(crate) async fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, @@ -469,6 +484,7 @@ pub(crate) async fn make_chatwidget_manual_with_sender() -> ( (widget, app_event_tx, rx, op_rx) } +/// Drains pending history insert events into renderable line buffers. fn drain_insert_history( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { @@ -485,6 +501,7 @@ fn drain_insert_history( out } +/// Flattens styled lines into a single newline-delimited string for asserts. fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { let mut s = String::new(); for line in lines { @@ -496,7 +513,9 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } +/// Builds a token usage info payload with a fixed context window. fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { + /// Builds a defaulted token usage payload with the provided total. fn usage(total_tokens: i64) -> TokenUsage { TokenUsage { total_tokens, @@ -511,6 +530,7 @@ fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { } } +/// Emits at most one warning per threshold level across primary and secondary limits. #[tokio::test] async fn rate_limit_warnings_emit_thresholds() { let mut state = RateLimitWarningState::default(); @@ -543,6 +563,7 @@ async fn rate_limit_warnings_emit_thresholds() { ); } +/// Emits monthly warnings once when the remaining percentage crosses thresholds. #[tokio::test] async fn test_rate_limit_warnings_monthly() { let mut state = RateLimitWarningState::default(); @@ -558,6 +579,7 @@ async fn test_rate_limit_warnings_monthly() { ); } +/// Preserves the prior credits balance when later snapshots omit credits. #[tokio::test] async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -607,6 +629,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { ); } +/// Updates plan type when supplied and retains the previous value when absent. #[tokio::test] async fn rate_limit_snapshot_updates_and_retains_plan_type() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -660,6 +683,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { assert_eq!(chat.plan_type, Some(PlanType::Pro)); } +/// Skips the model nudge prompt when already on the lower-cost model. #[tokio::test] async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; @@ -674,6 +698,7 @@ async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { )); } +/// Shows the model switch prompt only once per session even as usage increases. #[tokio::test] async fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); @@ -698,6 +723,7 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { )); } +/// Honors a stored notice preference to suppress the model switch prompt. #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); @@ -713,6 +739,7 @@ async fn rate_limit_switch_prompt_respects_hidden_notice() { )); } +/// Defers the model switch prompt until the task completes. #[tokio::test] async fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); @@ -734,6 +761,7 @@ async fn rate_limit_switch_prompt_defers_until_task_complete() { )); } +/// Snapshots the model switch popup after it becomes visible. #[tokio::test] async fn rate_limit_switch_prompt_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -749,6 +777,7 @@ async fn rate_limit_switch_prompt_popup_snapshot() { // (removed experimental resize snapshot test) +/// Approving an exec request emits decision history without leaking through history cells. #[tokio::test] async fn exec_approval_emits_proposed_command_and_decision_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -793,6 +822,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { ); } +/// Ensures multiline and long commands render in modals while history is truncated. #[tokio::test] async fn exec_approval_decision_truncates_multiline_and_long_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -878,7 +908,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { ); } -// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- +/// Emits an exec-begin event using the provided command source. fn begin_exec_with_source( chat: &mut ChatWidget, call_id: &str, @@ -908,6 +938,7 @@ fn begin_exec_with_source( event } +/// Emits a unified-exec startup begin event for snapshot tests. fn begin_unified_exec_startup( chat: &mut ChatWidget, call_id: &str, @@ -933,6 +964,7 @@ fn begin_unified_exec_startup( event } +/// Emits a terminal interaction event with captured stdin text. fn terminal_interaction(chat: &mut ChatWidget, call_id: &str, process_id: &str, stdin: &str) { chat.handle_codex_event(Event { id: call_id.to_string(), @@ -944,10 +976,12 @@ fn terminal_interaction(chat: &mut ChatWidget, call_id: &str, process_id: &str, }); } +/// Convenience wrapper for agent-sourced exec begin events. fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) } +/// Emits an exec-end event using the begin payload and output strings. fn end_exec( chat: &mut ChatWidget, begin_event: ExecCommandBeginEvent, @@ -991,6 +1025,7 @@ fn end_exec( }); } +/// Renders the active history cell to a single string for assertions. fn active_blob(chat: &ChatWidget) -> String { let lines = chat .active_cell @@ -1000,6 +1035,7 @@ fn active_blob(chat: &ChatWidget) -> String { lines_to_single_string(&lines) } +/// Looks up a model preset by name in the cached model list. fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { let models = chat .models_manager @@ -1012,6 +1048,7 @@ fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { .unwrap_or_else(|| panic!("{model} preset not found")) } +/// Leaves the queue empty when Enter is pressed with no composer text. #[tokio::test] async fn empty_enter_during_task_does_not_queue() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1026,6 +1063,7 @@ async fn empty_enter_during_task_does_not_queue() { assert!(chat.queued_user_messages.is_empty()); } +/// Moves the newest queued message back into the composer via Alt+Up. #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1059,6 +1097,7 @@ async fn alt_up_edits_most_recent_queued_message() { /// Pressing Up to recall the most recent history entry and immediately queuing /// it while a task is running should always enqueue the same text, even when it /// is queued repeatedly. +/// Re-queues recalled history prompts consistently across repeated cycles. #[tokio::test] async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1086,6 +1125,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { } } +/// Keeps task-running state while streaming a final answer and handling interrupts. #[tokio::test] async fn streaming_final_answer_keeps_task_running_state() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -1117,6 +1157,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } +/// Accepts Ctrl+C even with Caps Lock and requests a shutdown exit mode. #[tokio::test] async fn ctrl_c_shutdown_works_with_caps_lock() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1126,6 +1167,7 @@ async fn ctrl_c_shutdown_works_with_caps_lock() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +/// Exits immediately on Ctrl+D when no modal is active. #[tokio::test] async fn ctrl_d_quits_without_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1134,6 +1176,7 @@ async fn ctrl_d_quits_without_prompt() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +/// Ignores Ctrl+D when an approval modal is open. #[tokio::test] async fn ctrl_d_with_modal_open_does_not_quit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1144,6 +1187,7 @@ async fn ctrl_d_with_modal_open_does_not_quit() { assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } +/// Restores a cleared prompt via history recall after Ctrl+C interruption. #[tokio::test] async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -1178,6 +1222,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { ); } +/// Finalizes an exec cell with a success summary once the command ends. #[tokio::test] async fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1208,6 +1253,7 @@ async fn exec_history_cell_shows_working_then_completed() { ); } +/// Finalizes an exec cell with failure output when the command exits nonzero. #[tokio::test] async fn exec_history_cell_shows_working_then_failed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1232,6 +1278,7 @@ async fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +/// Uses the end event command when the begin event is missing. #[tokio::test] async fn exec_end_without_begin_uses_event_command() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1275,6 +1322,7 @@ async fn exec_end_without_begin_uses_event_command() { ); } +/// Renders unified-exec startup commands into history after completion. #[tokio::test] async fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1302,6 +1350,7 @@ async fn exec_history_shows_unified_exec_startup_commands() { ); } +/// Groups unified exec tool calls into the active "Explored" cell. #[tokio::test] async fn exec_history_shows_unified_exec_tool_calls() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1319,6 +1368,7 @@ async fn exec_history_shows_unified_exec_tool_calls() { assert_eq!(blob, "• Explored\n └ List ls\n"); } +/// Suppresses late unified-exec completion after a task completes. #[tokio::test] async fn unified_exec_end_after_task_complete_is_suppressed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1342,6 +1392,7 @@ async fn unified_exec_end_after_task_complete_is_suppressed() { ); } +/// Updates the status header when an exec wait gains command display text. #[tokio::test] async fn unified_exec_wait_status_header_updates_on_late_command_display() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1363,6 +1414,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { ); } +/// Snapshots the empty-wait unified exec state after multiple empty inputs. #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1390,6 +1442,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { assert_snapshot!("unified_exec_waiting_multiple_empty_after", combined); } +/// Snapshots unified exec output when empty input is followed by text. #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1406,6 +1459,7 @@ async fn unified_exec_empty_then_non_empty_snapshot() { assert_snapshot!("unified_exec_empty_then_non_empty_after", combined); } +/// Snapshots unified exec output when text is followed by empty input. #[tokio::test] async fn unified_exec_non_empty_then_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1447,8 +1501,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { assert_snapshot!("unified_exec_non_empty_then_empty_after", combined); } -/// Selecting the custom prompt option from the review popup sends -/// OpenReviewCustomPrompt to the app event channel. +/// Selecting the custom prompt option dispatches the OpenReviewCustomPrompt event. #[tokio::test] async fn review_popup_custom_prompt_action_sends_event() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1474,6 +1527,7 @@ async fn review_popup_custom_prompt_action_sends_event() { assert!(found, "expected OpenReviewCustomPrompt event to be sent"); } +/// Emits the existing-doc warning and skips `/init` when the project doc exists. #[tokio::test] async fn slash_init_skips_when_project_doc_exists() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -1506,6 +1560,7 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +/// Requests a shutdown exit when `/quit` is dispatched. #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1515,6 +1570,7 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +/// Requests a shutdown exit when `/exit` is dispatched. #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1524,6 +1580,7 @@ async fn slash_exit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +/// Opens the resume picker when `/resume` is dispatched. #[tokio::test] async fn slash_resume_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1533,6 +1590,7 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +/// Requests forking the current session when `/fork` is dispatched. #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1542,6 +1600,7 @@ async fn slash_fork_requests_current_fork() { assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); } +/// Renders the current rollout path when `/rollout` is dispatched. #[tokio::test] async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1559,6 +1618,7 @@ async fn slash_rollout_displays_current_path() { ); } +/// Shows a "not available" message when no rollout path is set. #[tokio::test] async fn slash_rollout_handles_missing_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1578,6 +1638,7 @@ async fn slash_rollout_handles_missing_path() { ); } +/// Emits a default success message and hides the status indicator on undo completion. #[tokio::test] async fn undo_success_events_render_info_messages() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1615,6 +1676,7 @@ async fn undo_success_events_render_info_messages() { ); } +/// Emits the provided error message and hides the status indicator on undo failure. #[tokio::test] async fn undo_failure_events_render_error_message() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1650,6 +1712,7 @@ async fn undo_failure_events_render_error_message() { ); } +/// Hides the interrupt hint while undo is in progress. #[tokio::test] async fn undo_started_hides_interrupt_hint() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1669,7 +1732,7 @@ async fn undo_started_hides_interrupt_hint() { ); } -/// The commit picker shows only commit subjects (no timestamps). +/// Ensures the review commit picker renders subjects without relative timestamps. #[tokio::test] async fn review_commit_picker_shows_subjects_without_timestamps() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1730,8 +1793,7 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { ); } -/// Submitting the custom prompt view sends Op::Review with the typed prompt -/// and uses the same text for the user-facing hint. +/// Submits the custom review prompt and trims it before sending the Op::Review. #[tokio::test] async fn custom_prompt_submit_sends_review_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1759,7 +1821,7 @@ async fn custom_prompt_submit_sends_review_op() { } } -/// Hitting Enter on an empty custom prompt view does not submit. +/// Leaves the custom prompt view idle when Enter is pressed without input. #[tokio::test] async fn custom_prompt_enter_empty_does_not_send() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1772,6 +1834,7 @@ async fn custom_prompt_enter_empty_does_not_send() { assert!(rx.try_recv().is_err(), "no app event should be sent"); } +/// Adds a history cell when the agent requests a local image view. #[tokio::test] async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1791,8 +1854,7 @@ async fn view_image_tool_call_adds_history_cell() { assert_snapshot!("local_image_attachment_history_snapshot", combined); } -// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ -// marker (replacing the spinner) and flushes it into history. +/// Snapshots the exec cell after an interruption finalizes it as failed. #[tokio::test] async fn interrupt_exec_marks_failed_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1820,8 +1882,7 @@ async fn interrupt_exec_marks_failed_snapshot() { assert_snapshot!("interrupt_exec_marks_failed", exec_blob); } -// Snapshot test: after an interrupted turn, a gentle error message is inserted -// suggesting the user to tell the model what to do differently and to use /feedback. +/// Snapshots the friendly error message emitted after an interrupted turn. #[tokio::test] async fn interrupted_turn_error_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1851,8 +1912,7 @@ async fn interrupted_turn_error_message_snapshot() { assert_snapshot!("interrupted_turn_error_message", last); } -/// Opening custom prompt from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +/// Verifies Esc navigates from custom prompt to parent popup and then closes. #[tokio::test] async fn review_custom_prompt_escape_navigates_back_then_dismisses() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1886,8 +1946,7 @@ async fn review_custom_prompt_escape_navigates_back_then_dismisses() { ); } -/// Opening base-branch picker from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). +/// Verifies Esc navigates from the base-branch picker back to the review popup. #[tokio::test] async fn review_branch_picker_escape_navigates_back_then_dismisses() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -1922,6 +1981,7 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { ); } +/// Returns the first non-empty rendered row for bottom-pane header assertions. fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); @@ -1944,6 +2004,7 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { String::new() } +/// Renders the bottom pane to a trimmed multiline string snapshot. fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); @@ -1975,6 +2036,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +/// Snapshots the experimental features popup rendering with mixed toggles. #[tokio::test] async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2000,6 +2062,7 @@ async fn experimental_features_popup_snapshot() { assert_snapshot!("experimental_features_popup", popup); } +/// Saves experimental feature toggles only when exiting the popup. #[tokio::test] async fn experimental_features_toggle_saves_on_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2040,6 +2103,7 @@ async fn experimental_features_toggle_saves_on_exit() { assert_eq!(updates, vec![(expected_feature, true)]); } +/// Snapshots the model selection popup with a running session. #[tokio::test] async fn model_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; @@ -2050,6 +2114,7 @@ async fn model_selection_popup_snapshot() { assert_snapshot!("model_selection_popup", popup); } +/// Filters out models marked as hidden from the picker snapshot. #[tokio::test] async fn model_picker_hides_show_in_picker_false_models_from_cache() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await; @@ -2086,6 +2151,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { ); } +/// Snapshots the approval mode selection popup. #[tokio::test] async fn approvals_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2102,6 +2168,7 @@ async fn approvals_selection_popup_snapshot() { assert_snapshot!("approvals_selection_popup", popup); } +/// Snapshots the approval popup in the Windows degraded sandbox configuration. #[cfg(target_os = "windows")] #[tokio::test] #[serial] @@ -2131,6 +2198,7 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { set_windows_elevated_sandbox_enabled(was_elevated_enabled); } +/// Matches the preset when extra writable roots are present in the sandbox policy. #[tokio::test] async fn preset_matching_ignores_extra_writable_roots() { let preset = builtin_approval_presets() @@ -2154,6 +2222,7 @@ async fn preset_matching_ignores_extra_writable_roots() { ); } +/// Snapshots the full access confirmation modal. #[tokio::test] async fn full_access_confirmation_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2168,6 +2237,7 @@ async fn full_access_confirmation_popup_snapshot() { assert_snapshot!("full_access_confirmation_popup", popup); } +/// Prompts the user to enable the Windows sandbox when auto mode requires it. #[cfg(target_os = "windows")] #[tokio::test] async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { @@ -2186,6 +2256,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { ); } +/// Shows the startup sandbox enable prompt when auto mode is downgraded. #[cfg(target_os = "windows")] #[tokio::test] async fn startup_prompts_for_windows_sandbox_when_agent_requested() { @@ -2213,6 +2284,7 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { set_windows_sandbox_enabled(true); } +/// Snapshots the reasoning effort selection popup for a model. #[tokio::test] async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; @@ -2227,6 +2299,7 @@ async fn model_reasoning_selection_popup_snapshot() { assert_snapshot!("model_reasoning_selection_popup", popup); } +/// Snapshots the extra-high reasoning warning variant of the popup. #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; @@ -2241,6 +2314,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { assert_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); } +/// Ensures the reasoning label uses "Extra high" spacing. #[tokio::test] async fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; @@ -2261,6 +2335,7 @@ async fn reasoning_popup_shows_extra_high_with_space() { ); } +/// Skips the reasoning picker when only one effort option exists. #[tokio::test] async fn single_reasoning_option_skips_selection() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2302,6 +2377,7 @@ async fn single_reasoning_option_skips_selection() { ); } +/// Snapshots the feedback category selection popup. #[tokio::test] async fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2313,6 +2389,7 @@ async fn feedback_selection_popup_snapshot() { assert_snapshot!("feedback_selection_popup", popup); } +/// Snapshots the feedback upload consent prompt. #[tokio::test] async fn feedback_upload_consent_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2324,6 +2401,7 @@ async fn feedback_upload_consent_popup_snapshot() { assert_snapshot!("feedback_upload_consent_popup", popup); } +/// Returns from the reasoning popup back to the model selection popup on Esc. #[tokio::test] async fn reasoning_popup_escape_returns_to_model_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; @@ -2343,6 +2421,7 @@ async fn reasoning_popup_escape_returns_to_model_popup() { assert!(!after_escape.contains("Select Reasoning Level")); } +/// Extends an active exec history cell when commands are consecutive. #[tokio::test] async fn exec_history_extends_previous_when_consecutive() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2374,6 +2453,7 @@ async fn exec_history_extends_previous_when_consecutive() { assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); } +/// Renders user shell output without tagging it as "exploring" history. #[tokio::test] async fn user_shell_command_renders_output_not_exploring() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2396,6 +2476,7 @@ async fn user_shell_command_renders_output_not_exploring() { assert_snapshot!("user_shell_ls_output", blob); } +/// Snapshots the error shown when a disabled slash command is run mid-task. #[tokio::test] async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task @@ -2415,6 +2496,7 @@ async fn disabled_slash_command_while_task_running_snapshot() { assert_snapshot!(blob); } +/// Renders disabled approval presets with their validation messages. #[tokio::test] async fn approvals_popup_shows_disabled_presets() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2451,6 +2533,7 @@ async fn approvals_popup_shows_disabled_presets() { ); } +/// Skips disabled approval presets during navigation and selection. #[tokio::test] async fn approvals_popup_navigation_skips_disabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -2520,11 +2603,7 @@ async fn approvals_popup_navigation_skips_disabled() { ); } -// -// Snapshot test: command approval modal -// -// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal -// and snapshots the visual output using the ratatui TestBackend. +/// Snapshots the command approval modal using a synthetic exec request. #[tokio::test] async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { // Build a chat widget with manual channels to avoid spawning the agent. @@ -2580,8 +2659,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { Ok(()) } -// Snapshot test: command approval modal without a reason -// Ensures spacing looks correct when no reason text is provided. +/// Snapshots the command approval modal when no reason text is provided. #[tokio::test] async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2621,8 +2699,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { Ok(()) } -// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line; -// we should not offer adding it to execpolicy. +/// Hides the execpolicy option when the proposed prefix spans multiple lines. #[tokio::test] async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() -> anyhow::Result<()> { @@ -2663,7 +2740,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() Ok(()) } -// Snapshot test: patch approval modal +/// Snapshots the patch approval modal with a single-file change. #[tokio::test] async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2705,6 +2782,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> { Ok(()) } +/// Restores queued messages into the composer when a task is interrupted. #[tokio::test] async fn interrupt_restores_queued_messages_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -2744,6 +2822,7 @@ async fn interrupt_restores_queued_messages_into_composer() { let _ = drain_insert_history(&mut rx); } +/// Inserts queued messages ahead of existing draft text on interruption. #[tokio::test] async fn interrupt_prepends_queued_messages_before_existing_composer_text() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -2778,6 +2857,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { let _ = drain_insert_history(&mut rx); } +/// Clears unified exec process summaries when a turn is interrupted. #[tokio::test] async fn interrupt_clears_unified_exec_processes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2798,6 +2878,7 @@ async fn interrupt_clears_unified_exec_processes() { let _ = drain_insert_history(&mut rx); } +/// Clears unified exec process summaries when a turn completes normally. #[tokio::test] async fn turn_complete_clears_unified_exec_processes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2818,8 +2899,7 @@ async fn turn_complete_clears_unified_exec_processes() { let _ = drain_insert_history(&mut rx); } -// Snapshot test: ChatWidget at very small heights (idle) -// Ensures overall layout behaves when terminal height is extremely constrained. +/// Snapshots the idle layout at extremely small terminal heights. #[tokio::test] async fn ui_snapshots_small_heights_idle() { use ratatui::Terminal; @@ -2835,8 +2915,7 @@ async fn ui_snapshots_small_heights_idle() { } } -// Snapshot test: ChatWidget at very small heights (task running) -// Validates how status + composer are presented within tight space. +/// Snapshots the running-task layout at extremely small terminal heights. #[tokio::test] async fn ui_snapshots_small_heights_task_running() { use ratatui::Terminal; @@ -2865,9 +2944,7 @@ async fn ui_snapshots_small_heights_task_running() { } } -// Snapshot test: status widget + approval modal active together -// The modal takes precedence visually; this captures the layout with a running -// task (status indicator active) while an approval request is shown. +/// Snapshots the layout when both the status indicator and approval modal are visible. #[tokio::test] async fn status_widget_and_approval_modal_snapshot() { use codex_core::protocol::ExecApprovalRequestEvent; @@ -2920,8 +2997,7 @@ async fn status_widget_and_approval_modal_snapshot() { assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); } -// Snapshot test: status widget active (StatusIndicatorView) -// Ensures the VT100 rendering of the status indicator is stable when active. +/// Snapshots the active status indicator rendering in isolation. #[tokio::test] async fn status_widget_active_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2949,6 +3025,7 @@ async fn status_widget_active_snapshot() { assert_snapshot!("status_widget_active", terminal.backend()); } +/// Snapshots the MCP startup header while the server is booting. #[tokio::test] async fn mcp_startup_header_booting_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2971,6 +3048,7 @@ async fn mcp_startup_header_booting_snapshot() { assert_snapshot!("mcp_startup_header_booting", terminal.backend()); } +/// Leaves the running-task state intact after MCP startup completes. #[tokio::test] async fn mcp_startup_complete_does_not_clear_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2997,6 +3075,7 @@ async fn mcp_startup_complete_does_not_clear_running_task() { assert!(chat.bottom_pane.status_indicator_visible()); } +/// Updates the status header in place when background events arrive. #[tokio::test] async fn background_event_updates_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3013,6 +3092,7 @@ async fn background_event_updates_status_header() { assert!(drain_insert_history(&mut rx).is_empty()); } +/// Surfaces patch approval, begin, and end events with the expected cells. #[tokio::test] async fn apply_patch_events_emit_history_cells() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3111,6 +3191,7 @@ async fn apply_patch_events_emit_history_cells() { ); } +/// Adjusts the apply header when a patch requires manual approval. #[tokio::test] async fn apply_patch_manual_approval_adjusts_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3160,6 +3241,7 @@ async fn apply_patch_manual_approval_adjusts_header() { ); } +/// Snapshots the manual approval flow once the patch apply begins. #[tokio::test] async fn apply_patch_manual_flow_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3213,6 +3295,7 @@ async fn apply_patch_manual_flow_snapshot() { ); } +/// Sends the submission id (not call id) when approving a patch. #[tokio::test] async fn apply_patch_approval_sends_op_with_submission_id() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3252,6 +3335,7 @@ async fn apply_patch_approval_sends_op_with_submission_id() { assert!(found, "expected PatchApproval op to be sent"); } +/// Exercises the full apply-patch flow from approval to begin/end events. #[tokio::test] async fn apply_patch_full_flow_integration_like() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -3330,6 +3414,7 @@ async fn apply_patch_full_flow_integration_like() { }); } +/// Shows the patch approval modal when approval policy is untrusted. #[tokio::test] async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3377,6 +3462,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> { Ok(()) } +/// Renders a diff summary in the approval modal for simple patches. #[tokio::test] async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3445,6 +3531,7 @@ async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> { Ok(()) } +/// Renders the plan update payload into a single history cell. #[tokio::test] async fn plan_update_renders_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3481,6 +3568,7 @@ async fn plan_update_renders_history_cell() { assert!(blob.contains("Write tests")); } +/// Updates the status indicator on stream errors without writing history. #[tokio::test] async fn stream_error_updates_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3509,6 +3597,7 @@ async fn stream_error_updates_status_indicator() { assert_eq!(status.details(), Some(details)); } +/// Adds a warning history cell when a Warning event arrives. #[tokio::test] async fn warning_event_adds_warning_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3528,6 +3617,7 @@ async fn warning_event_adds_warning_history_cell() { ); } +/// Restores the previous status header after stream recovery. #[tokio::test] async fn stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3563,6 +3653,7 @@ async fn stream_recovery_restores_previous_status_header() { assert!(chat.retry_status_header.is_none()); } +/// Emits multiple assistant message headers within a single turn. #[tokio::test] async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3617,6 +3708,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { assert!(first_idx < second_idx, "messages out of order: {combined}"); } +/// Renders final reasoning and message events without deltas. #[tokio::test] async fn final_reasoning_then_message_without_deltas_are_rendered() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3644,6 +3736,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { assert_snapshot!(combined); } +/// Renders delta streams followed by identical final messages without duplication. #[tokio::test] async fn deltas_then_same_final_message_are_rendered_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3705,9 +3798,7 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { assert_snapshot!(combined); } -// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. -// This renders the final visual as seen in a terminal: history above, then a blank line, -// then the exec block, another blank line, the status line, a blank line, and the composer. +/// Snapshots a combined vt100 history + UI overlay layout for exec and status. #[tokio::test] async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3799,7 +3890,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { assert_snapshot!(term.backend().vt100().screen().contents()); } -// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks +/// Snapshots vt100 output for complex markdown with nested code blocks. #[tokio::test] async fn chatwidget_markdown_code_blocks_vt100_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3891,6 +3982,7 @@ printf 'fenced within fenced\n' assert_snapshot!(term.backend().vt100().screen().contents()); } +/// Snapshots a tall chat widget with queued user messages. #[tokio::test] async fn chatwidget_tall() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3917,6 +4009,7 @@ async fn chatwidget_tall() { assert_snapshot!(term.backend().vt100().screen().contents()); } +/// Snapshots queued message rendering while review mode is active. #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 8308f1c2a6..2527b965cc 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,9 +1,24 @@ +//! CLI argument definitions for the Codex TUI. +//! +//! This module models both the user-facing flags and the internal control +//! switches passed in from wrapper subcommands such as `codex resume` and +//! `codex fork`. The internal fields are intentionally hidden from `clap` so +//! the base `codex` command surface stays stable while higher-level commands +//! can reuse the same launch path. + use clap::Parser; use clap::ValueHint; use codex_common::ApprovalModeCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; +/// CLI arguments for launching the Codex TUI. +/// +/// The struct mixes user-visible options (prompt content, model selection, and +/// execution policy) with internal control fields set by subcommands. The +/// hidden fields let higher-level commands reuse the same launch path without +/// exposing extra flags on the base command, so callers must only populate +/// them through the parent CLI layer. #[derive(Parser, Debug)] #[command(version)] pub struct Cli { @@ -15,16 +30,17 @@ pub struct Cli { #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] pub images: Vec, - // Internal controls set by the top-level `codex resume` subcommand. - // These are not exposed as user flags on the base `codex` command. + /// Internal: show the resume picker UI instead of starting a new session. + /// Set by the top-level `codex resume` subcommand; not exposed as a flag. #[clap(skip)] pub resume_picker: bool, + /// Internal: resume the most recent session without showing a picker. #[clap(skip)] pub resume_last: bool, - /// Internal: resume a specific recorded session by id (UUID). Set by the - /// top-level `codex resume ` wrapper; not exposed as a public flag. + /// Internal: resume a specific recorded session by id (UUID). + /// Set by the top-level `codex resume ` wrapper; not exposed as a public flag. #[clap(skip)] pub resume_session_id: Option, @@ -32,16 +48,17 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: bool, - // Internal controls set by the top-level `codex fork` subcommand. - // These are not exposed as user flags on the base `codex` command. + /// Internal: show the fork picker UI instead of forking immediately. + /// Set by the top-level `codex fork` subcommand; not exposed as a flag. #[clap(skip)] pub fork_picker: bool, + /// Internal: fork the most recent session without showing a picker. #[clap(skip)] pub fork_last: bool, - /// Internal: fork a specific recorded session by id (UUID). Set by the - /// top-level `codex fork ` wrapper; not exposed as a public flag. + /// Internal: fork a specific recorded session by id (UUID). + /// Set by the top-level `codex fork ` wrapper; not exposed as a public flag. #[clap(skip)] pub fork_session_id: Option, @@ -49,26 +66,25 @@ pub struct Cli { #[clap(skip)] pub fork_show_all: bool, - /// Model the agent should use. + /// Model the agent should use, overriding any configured default. #[arg(long, short = 'm')] pub model: Option, - /// Convenience flag to select the local open source model provider. Equivalent to -c - /// model_provider=oss; verifies a local LM Studio or Ollama server is running. + /// Convenience flag to select the local open source model provider. + /// Equivalent to `-c model_provider=oss`; verifies a local LM Studio or Ollama server is running. #[arg(long = "oss", default_value_t = false)] pub oss: bool, /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). - /// If not specified with --oss, will use config default or show selection. + /// If not specified with `--oss`, will use the config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, - /// Configuration profile from config.toml to specify default options. + /// Configuration profile from `config.toml` to specify default options. #[arg(long = "profile", short = 'p')] pub config_profile: Option, - /// Select the sandbox policy to use when executing model-generated shell - /// commands. + /// Select the sandbox policy to use when executing model-generated shell commands. #[arg(long = "sandbox", short = 's')] pub sandbox_mode: Option, @@ -76,7 +92,8 @@ pub struct Cli { #[arg(long = "ask-for-approval", short = 'a')] pub approval_policy: Option, - /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). + /// Convenience alias for low-friction sandboxed automatic execution + /// (`-a on-request`, `--sandbox workspace-write`). #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, @@ -94,7 +111,8 @@ pub struct Cli { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, - /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). + /// Enable live web search. + /// When enabled, the native Responses `web_search` tool is available to the model without per-call approval. #[arg(long = "search", default_value_t = false)] pub web_search: bool, @@ -102,7 +120,7 @@ pub struct Cli { #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] pub add_dir: Vec, - /// Disable alternate screen mode + /// Disable alternate screen mode. /// /// Runs the TUI in inline mode, preserving terminal scrollback history. This is useful /// in terminal multiplexers like Zellij that follow the xterm spec strictly and disable @@ -110,6 +128,7 @@ pub struct Cli { #[arg(long = "no-alt-screen", default_value_t = false)] pub no_alt_screen: bool, + /// Internal: resolved config overrides for downstream processing. #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 5863c728b0..8c326155df 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -1,12 +1,24 @@ +//! Clipboard paste utilities for images and file paths. +//! +//! Image pasting prefers native clipboard images, falling back to file-backed clipboard entries +//! and (on WSL) a PowerShell bridge that writes the Windows clipboard image to a temporary file. +//! Path normalization handles `file://` URLs, Windows/UNC paths, and shell-escaped POSIX paths so +//! the caller can treat pasted text as a candidate filesystem path. + use std::path::Path; use std::path::PathBuf; use tempfile::Builder; +/// Errors that can occur when pasting images from the system clipboard. #[derive(Debug, Clone)] pub enum PasteImageError { + /// Clipboard access failed or is unavailable. ClipboardUnavailable(String), + /// Clipboard is accessible but contains no image data. NoImage(String), + /// Encoding or image buffer conversion failed. EncodeFailed(String), + /// I/O operations (temp file creation, writes) failed. IoError(String), } @@ -24,12 +36,16 @@ impl std::error::Error for PasteImageError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncodedImageFormat { + /// PNG image data. Png, + /// JPEG image data. Jpeg, + /// Unknown or unsupported image format. Other, } impl EncodedImageFormat { + /// Return a short uppercase label for UI display. pub fn label(self) -> &'static str { match self { EncodedImageFormat::Png => "PNG", @@ -39,14 +55,21 @@ impl EncodedImageFormat { } } +/// Metadata describing a pasted image. #[derive(Debug, Clone)] pub struct PastedImageInfo { + /// Pixel width of the image. pub width: u32, + /// Pixel height of the image. pub height: u32, + /// Encoded image format reported to the UI. pub encoded_format: EncodedImageFormat, // Always PNG for now. } /// Capture image from system clipboard, encode to PNG, and return bytes + info. +/// +/// This attempts file-backed clipboard entries first (for example Finder copy), then falls back +/// to direct clipboard image bytes (for example from a browser). #[cfg(not(target_os = "android"))] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { let _span = tracing::debug_span!("paste_image_as_png").entered(); @@ -229,6 +252,7 @@ fn try_dump_windows_clipboard_image() -> Option { } #[cfg(target_os = "android")] +/// Android/Termux does not support clipboard image pasting; return a clear error. pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { // Keep error consistent with paste_image_as_png. Err(PasteImageError::ClipboardUnavailable( @@ -295,6 +319,7 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { None } +/// Best-effort detection of WSL environments for clipboard fallbacks. #[cfg(target_os = "linux")] pub(crate) fn is_probably_wsl() -> bool { // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). @@ -311,6 +336,7 @@ pub(crate) fn is_probably_wsl() -> bool { std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() } +/// Convert a Windows drive-letter path (C:\\...) into a WSL `/mnt/` path. #[cfg(target_os = "linux")] fn convert_windows_path_to_wsl(input: &str) -> Option { if input.starts_with("\\\\") { diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/collab.rs index 5a1a18f63c..ec87b01510 100644 --- a/codex-rs/tui/src/collab.rs +++ b/codex-rs/tui/src/collab.rs @@ -1,3 +1,8 @@ +//! Render collaboration events into transcript-friendly history cells. +//! +//! These helpers take structured collab events from the backend and turn them into a compact set +//! of `PlainHistoryCell` lines with consistent indentation and status formatting. + use crate::history_cell::PlainHistoryCell; use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; @@ -10,8 +15,10 @@ use codex_core::protocol::CollabWaitingEndEvent; use ratatui::style::Stylize; use ratatui::text::Line; +/// Maximum number of graphemes shown when previewing collab prompts. const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; +/// Render a "collab spawn" event as a history cell. pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { call_id, @@ -35,6 +42,7 @@ pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { collab_event("Collab spawn", details) } +/// Render a "collab send input" event as a history cell. pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { call_id, @@ -55,6 +63,7 @@ pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistor collab_event("Collab send input", details) } +/// Render a "collab wait begin" event as a history cell. pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, @@ -69,6 +78,7 @@ pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { collab_event("Collab wait begin", details) } +/// Render a "collab wait end" event as a history cell. pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, @@ -83,6 +93,7 @@ pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { collab_event("Collab wait end", details) } +/// Render a "collab close" event as a history cell. pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { call_id, @@ -99,6 +110,7 @@ pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { collab_event("Collab close", details) } +/// Assemble a collab event title and detail lines into a single history cell. fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { let title = title.into(); let mut lines: Vec> = vec![vec!["• ".dim(), title.bold()].into()]; @@ -108,14 +120,17 @@ fn collab_event(title: impl Into, details: Vec>) -> PlainH PlainHistoryCell::new(lines) } +/// Format a single `label: value` line with dim styling. fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> { Line::from(format!("{label}: {value}").dim()) } +/// Format a status line using the shared status labels. fn status_line(status: &AgentStatus) -> Line<'static> { Line::from(format!("status: {}", status_text(status)).dim()) } +/// Map an agent status enum into a stable transcript label. fn status_text(status: &AgentStatus) -> &'static str { match status { AgentStatus::PendingInit => "pending_init", @@ -127,6 +142,7 @@ fn status_text(status: &AgentStatus) -> &'static str { } } +/// Build a prompt preview line if the prompt contains any non-whitespace text. fn prompt_line(prompt: &str) -> Option> { let trimmed = prompt.trim(); if trimmed.is_empty() { diff --git a/codex-rs/tui/src/color.rs b/codex-rs/tui/src/color.rs index f5121a1f6c..3ac421c6da 100644 --- a/codex-rs/tui/src/color.rs +++ b/codex-rs/tui/src/color.rs @@ -1,9 +1,18 @@ +//! Small color utilities for UI theming. +//! +//! These helpers provide luminance checks, alpha blending, and a perceptual distance metric for +//! tuning contrast decisions in the TUI. + +/// Return true when the background is considered light by luminance. pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool { let (r, g, b) = bg; let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; y > 128.0 } +/// Alpha-blend `fg` over `bg` using the provided opacity. +/// +/// `alpha` is interpreted as the foreground opacity in `[0.0, 1.0]`. pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8; let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8; diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 46d16a83f0..325b458998 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -1,3 +1,12 @@ +//! A lightweight terminal wrapper derived from `ratatui::Terminal` with viewport support. +//! +//! This module keeps a pair of buffers, diffs them into drawing commands, and applies the minimal +//! set of changes through the backend. It also tracks a viewport rectangle so callers can render +//! into a sub-region without resizing the underlying terminal, which is important for tests and +//! multi-pane layouts. +//! +//! Cursor handling is centralized in [`Frame`], which records the final cursor position during +//! rendering so the terminal updates can be flushed before the cursor is moved. // This is derived from `ratatui::Terminal`, which is licensed under the following terms: // // The MIT License (MIT) @@ -44,6 +53,7 @@ use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::widgets::WidgetRef; +/// A snapshot of terminal state and buffers used while rendering a single frame. #[derive(Debug, Hash)] pub struct Frame<'a> { /// Where should the cursor be after drawing this frame? @@ -74,7 +84,7 @@ impl Frame<'_> { /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. /// /// Usually the area argument is the size of the current frame or a sub-area of the current - /// frame (which can be obtained using [`Layout`] to split the total area). + /// frame (which can be obtained using [`ratatui::layout::Layout`] to split the total area). #[allow(clippy::needless_pass_by_value)] pub fn render_widget_ref(&mut self, widget: W, area: Rect) { widget.render_ref(area, self.buffer); @@ -86,10 +96,6 @@ impl Frame<'_> { /// Note that this will interfere with calls to [`Terminal::hide_cursor`], /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and /// stick with it. - /// - /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor - /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor - /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position pub fn set_cursor_position>(&mut self, position: P) { self.cursor_position = Some(position.into()); } @@ -100,26 +106,25 @@ impl Frame<'_> { } } +/// A terminal wrapper that tracks buffers, viewport state, and cursor visibility. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct Terminal where B: Backend + Write, { - /// The backend used to interface with the terminal + /// The backend used to interface with the terminal. backend: B, - /// Holds the results of the current and previous draw calls. The two are compared at the end - /// of each draw pass to output the necessary updates to the terminal + /// Holds the results of the current and previous draw calls for diffing. buffers: [Buffer; 2], - /// Index of the current buffer in the previous array + /// Index of the current buffer in the buffer pair. current: usize, - /// Whether the cursor is currently hidden + /// Whether the cursor is currently hidden. pub hidden_cursor: bool, - /// Area of the viewport + /// Area of the viewport, in terminal coordinates. pub viewport_area: Rect, - /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. + /// Last known size of the terminal, used to detect size changes. pub last_known_screen_size: Size, - /// Last known position of the cursor. Used to find the new area when the viewport is inlined - /// and the terminal resized. + /// Last known position of the cursor, used when the viewport is anchored to the cursor. pub last_known_cursor_pos: Position, } @@ -144,7 +149,7 @@ where B: Backend, B: Write, { - /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. + /// Creates a new [`Terminal`] with the given backend, using its size and cursor position. pub fn with_options(mut backend: B) -> io::Result { let screen_size = backend.size()?; let cursor_pos = backend.get_cursor_position()?; @@ -159,7 +164,7 @@ where }) } - /// Get a Frame object which provides a consistent view into the terminal state for rendering. + /// Gets a `Frame` that provides a stable view into the terminal state for rendering. pub fn get_frame(&mut self) -> Frame<'_> { Frame { cursor_position: None, @@ -188,18 +193,17 @@ where &mut self.buffers[1 - self.current] } - /// Gets the backend + /// Gets the backend. pub const fn backend(&self) -> &B { &self.backend } - /// Gets the backend as a mutable reference + /// Gets the backend as a mutable reference. pub fn backend_mut(&mut self) -> &mut B { &mut self.backend } - /// Obtains a difference between the previous and the current buffer and passes it to the - /// current backend for drawing. + /// Diffs the buffers and passes the resulting draw commands to the backend. pub fn flush(&mut self) -> io::Result<()> { let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); let last_put_command = updates.iter().rfind(|command| command.is_put()); @@ -209,23 +213,22 @@ where draw(&mut self.backend, updates.into_iter()) } - /// Updates the Terminal so that internal buffers match the requested area. + /// Updates the cached terminal size so it can be detected on the next draw pass. /// - /// Requested area will be saved to remain consistent when rendering. This leads to a full clear - /// of the screen. + /// The actual buffers are resized when the viewport area is set. pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { self.last_known_screen_size = screen_size; Ok(()) } - /// Sets the viewport area. + /// Sets the viewport area and resizes both backing buffers to match it. pub fn set_viewport_area(&mut self, area: Rect) { self.current_buffer_mut().resize(area); self.previous_buffer_mut().resize(area); self.viewport_area = area; } - /// Queries the backend for size and resizes if it doesn't match the previous size. + /// Queries the backend for size changes and updates the cached size. pub fn autoresize(&mut self) -> io::Result<()> { let screen_size = self.size()?; if screen_size != self.last_known_screen_size { @@ -236,7 +239,7 @@ where /// Draws a single frame to the terminal. /// - /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. + /// Returns `Ok(())` if successful, otherwise a [`std::io::Error`]. /// /// If the render callback passed to this method can fail, use [`try_draw`] instead. /// @@ -269,8 +272,7 @@ where /// Tries to draw a single frame to the terminal. /// - /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise - /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. + /// Returns `Ok(())` if successful, otherwise the [`std::io::Error`] that caused the failure. /// /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or /// closure that returns a `Result` instead of nothing. @@ -286,7 +288,7 @@ where /// - call the render callback, passing it a [`Frame`] reference to render to /// - flush the current internal state by copying the current buffer to the backend /// - move the cursor to the last known position if it was set during the rendering closure - /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal + /// - leave the terminal in a consistent state for the next diff /// /// The render callback passed to `try_draw` can return any [`Result`] with an error type that /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible @@ -294,8 +296,7 @@ where /// callback returns an error, the error will be returned from `try_draw` as an /// [`std::io::Error`] and the terminal will not be updated. /// - /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing - /// purposes, but it is often not used in regular applicationss. + /// When the render callback fails, the terminal is left unchanged. /// /// The render callback should fully render the entire frame when called, including areas that /// are unchanged from the previous frame. This is because each frame is compared to the @@ -368,7 +369,7 @@ where Ok(()) } - /// Clear the terminal and force a full redraw on the next draw call. + /// Clears the viewport and forces a full redraw on the next draw call. pub fn clear(&mut self) -> io::Result<()> { if self.viewport_area.is_empty() { return Ok(()); @@ -381,7 +382,7 @@ where Ok(()) } - /// Clears the inactive buffer and swaps it with the current buffer + /// Clears the inactive buffer and swaps it with the current buffer. pub fn swap_buffers(&mut self) { self.previous_buffer_mut().reset(); self.current = 1 - self.current; @@ -396,12 +397,16 @@ where use ratatui::buffer::Cell; use unicode_width::UnicodeWidthStr; +/// A diff command describing how to update the terminal for one buffer cell or run. #[derive(Debug, IsVariant)] enum DrawCommand { + /// Update a single cell at the given terminal coordinates. Put { x: u16, y: u16, cell: Cell }, + /// Clear to the end of the line starting at `(x, y)` with the given background. ClearToEnd { x: u16, y: u16, bg: Color }, } +/// Computes the draw commands needed to transform buffer `a` into buffer `b`. fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { let previous_buffer = &a.content; let next_buffer = &b.content; @@ -464,6 +469,7 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { updates } +/// Writes the draw commands to the terminal backend in sequence. fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> where I: Iterator, @@ -523,15 +529,16 @@ where Ok(()) } -/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier` -/// values. This is useful when updating the terminal display, as it allows for more -/// efficient updates by only sending the necessary changes. +/// The modifier delta to apply when switching between two style modifier sets. struct ModifierDiff { + /// The modifier state currently applied. pub from: Modifier, + /// The modifier state to apply. pub to: Modifier, } impl ModifierDiff { + /// Queues the minimal set of modifier changes to transition from `from` to `to`. fn queue(self, w: &mut W) -> io::Result<()> { use crossterm::style::Attribute as CAttribute; let removed = self.from - self.to; @@ -597,6 +604,7 @@ mod tests { use ratatui::layout::Rect; use ratatui::style::Style; + /// Avoids emitting ClearToEnd when the last nonblank cell is at the row edge. #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { let area = Rect::new(0, 0, 3, 2); @@ -625,6 +633,7 @@ mod tests { ); } + /// Starts ClearToEnd after the remaining wide character in the row. #[test] fn diff_buffers_clear_to_end_starts_after_wide_char() { let area = Rect::new(0, 0, 10, 1); diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 505ebc8edc..763083a62d 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -1,3 +1,10 @@ +//! Diff rendering helpers for bottom-pane views and summaries. +//! +//! This module turns [`FileChange`] payloads into `ratatui` `Line` values for +//! display in the UI. It supports both summary output (file list with counts) +//! and detailed diff rendering with line numbers, wrapping, and styled gutters. +//! Path rendering is normalized to keep output stable across cwd or VCS layouts. + use diffy::Hunk; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -21,31 +28,37 @@ use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; -// Internal representation for diff line rendering +/// Classification of a diff line for styling and sign prefixing. enum DiffLineType { Insert, Delete, Context, } +/// Summary view for a set of file changes. pub struct DiffSummary { + /// Mapping from paths to file changes. changes: HashMap, + /// Working directory used to shorten displayed paths. cwd: PathBuf, } impl DiffSummary { + /// Creates a summary view for the given changes. pub fn new(changes: HashMap, cwd: PathBuf) -> Self { Self { changes, cwd } } } impl Renderable for FileChange { + /// Renders a single file change as a diff block. fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines = vec![]; render_change(self, &mut lines, area.width as usize); Paragraph::new(lines).render(area, buf); } + /// Returns the height required to render the diff block. fn desired_height(&self, width: u16) -> u16 { let mut lines = vec![]; render_change(self, &mut lines, width as usize); @@ -54,6 +67,7 @@ impl Renderable for FileChange { } impl From for Box { + /// Builds a column renderable with per-file headers and diff bodies. fn from(val: DiffSummary) -> Self { let mut rows: Vec> = vec![]; @@ -85,17 +99,23 @@ pub(crate) fn create_diff_summary( render_changes_block(rows, wrap_cols, cwd) } -// Shared row for per-file presentation +/// Shared row for per-file presentation in summaries. #[derive(Clone)] struct Row { #[allow(dead_code)] + /// Original path for the file change. path: PathBuf, + /// Optional destination path for moved files. move_path: Option, + /// Total inserted line count. added: usize, + /// Total removed line count. removed: usize, + /// File change payload for rendering. change: FileChange, } +/// Collects file rows and computes line counts for summary rendering. fn collect_rows(changes: &HashMap) -> Vec { let mut rows: Vec = Vec::new(); for (path, change) in changes.iter() { @@ -123,6 +143,7 @@ fn collect_rows(changes: &HashMap) -> Vec { rows } +/// Builds a `(+/-)` line count summary span list. fn render_line_count_summary(added: usize, removed: usize) -> Vec> { let mut spans = Vec::new(); spans.push("(".into()); @@ -133,6 +154,7 @@ fn render_line_count_summary(added: usize, removed: usize) -> Vec, wrap_cols: usize, cwd: &Path) -> Vec> { let mut out: Vec> = Vec::new(); @@ -193,6 +215,7 @@ fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec>, width: usize) { match change { FileChange::Add { content } => { @@ -299,9 +322,12 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi } } -/// Format a path for display relative to the current working directory when -/// possible, keeping output stable in jj/no-`.git` workspaces (e.g. image -/// tool calls should show `example.png` instead of an absolute path). +/// Formats a path for display relative to the current working directory. +/// +/// The formatting prefers a cwd-relative path when possible, falls back to a +/// repo-relative path when both `cwd` and the target live in the same Git +/// repository, and finally uses a home-relative path for stable output in +/// environments without a `.git` directory (for example, jj workspaces). pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { if path.is_relative() { return path.display().to_string(); @@ -325,6 +351,7 @@ pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { chosen.display().to_string() } +/// Computes added/removed counts from a unified diff string. fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { if let Ok(patch) = diffy::Patch::from_str(diff) { patch @@ -342,6 +369,7 @@ fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { } } +/// Builds one or more wrapped lines for a diff row. fn push_wrapped_diff_line( line_number: usize, kind: DiffLineType, @@ -403,6 +431,7 @@ fn push_wrapped_diff_line( lines } +/// Returns the display width of the largest line number. fn line_number_width(max_line_number: usize) -> usize { if max_line_number == 0 { 1 @@ -411,18 +440,22 @@ fn line_number_width(max_line_number: usize) -> usize { } } +/// Style used for the line-number gutter. fn style_gutter() -> Style { Style::default().add_modifier(Modifier::DIM) } +/// Style used for context lines. fn style_context() -> Style { Style::default() } +/// Style used for inserted lines. fn style_add() -> Style { Style::default().fg(Color::Green) } +/// Style used for deleted lines. fn style_del() -> Style { Style::default().fg(Color::Red) } diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs index 906091113e..b22ba84aec 100644 --- a/codex-rs/tui/src/exec_cell/mod.rs +++ b/codex-rs/tui/src/exec_cell/mod.rs @@ -1,12 +1,32 @@ +//! Re-exports exec cell data and rendering helpers for the TUI. +//! +//! An exec cell represents a shell or tool invocation and its streamed output +//! as rendered in the transcript. This module is a narrow façade that gathers +//! the data model from [`model`] and the rendering helpers from [`render`], so +//! other modules can import the exec cell API without depending on submodules +//! directly. +//! +//! The re-exports here are crate-private to keep the exec cell implementation +//! cohesive while still allowing the rest of the TUI to build rows, compute +//! line limits, and create live “active command” placeholders during streaming. + mod model; mod render; +/// Captures the formatted output associated with an exec cell. pub(crate) use model::CommandOutput; +/// Exposes exec call fixtures for exec cell tests. #[cfg(test)] pub(crate) use model::ExecCall; +/// Owns the state and metadata needed to render a single exec cell. pub(crate) use model::ExecCell; +/// Bundles parameters that control output line shaping and truncation. pub(crate) use render::OutputLinesParams; +/// Maximum number of lines rendered for tool call output. pub(crate) use render::TOOL_CALL_MAX_LINES; +/// Builds a transient exec cell for a currently running command. pub(crate) use render::new_active_exec_command; +/// Converts an exec cell into displayable output lines. pub(crate) use render::output_lines; +/// Produces a spinner glyph used for running exec cells. pub(crate) use render::spinner; diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 21799c6199..e0cf323715 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -1,37 +1,91 @@ +//! Data model for exec history cells shown in the transcript. +//! +//! An exec cell groups one or more command executions that should render together in the chat +//! transcript. Each execution is represented as an [`ExecCall`] with optional output once the +//! command completes. The cell also keeps track of whether animations are enabled so rendering can +//! opt into live updates without duplicating the state. +//! +//! "Exploring" calls are a subset of execs (read/list/search actions that are not user shell +//! commands). Consecutive exploring calls may be coalesced into the same cell so the transcript +//! presents a single exploratory block rather than a sequence of individual commands. +//! +//! The exec cell owns its calls and timestamps. Call completion clears the start time and fills in +//! the duration/output fields; callers should treat those fields as mutually exclusive with +//! "in-flight" state. + use std::time::Duration; use std::time::Instant; use codex_core::protocol::ExecCommandSource; use codex_protocol::parse_command::ParsedCommand; +/// Captures a command's output once it finishes. +/// +/// This struct stores both the raw interleaved output and the formatted +/// representation shown to the model, allowing rendering to choose the +/// appropriate view. #[derive(Clone, Debug, Default)] pub(crate) struct CommandOutput { + /// Exit status reported by the command. pub(crate) exit_code: i32, + /// The aggregated stderr + stdout interleaved. pub(crate) aggregated_output: String, + /// The formatted output of the command, as seen by the model. pub(crate) formatted_output: String, } +/// Records a single exec tool call and its eventual output. +/// +/// The call starts with `output` unset and `start_time` populated. Completion +/// fills in `output` and `duration`, while clearing `start_time`. #[derive(Debug, Clone)] pub(crate) struct ExecCall { + /// Unique identifier for the tool call, used to match begin/end events. pub(crate) call_id: String, + + /// The raw command tokens as invoked. pub(crate) command: Vec, + + /// Parsed command classification used to detect "exploring" calls. pub(crate) parsed: Vec, + + /// Output captured after completion; `None` while the call is running. pub(crate) output: Option, + + /// Source of the command (user shell, unified exec, etc.). pub(crate) source: ExecCommandSource, + + /// Start time for in-flight calls; cleared when the call completes. pub(crate) start_time: Option, + + /// Duration of the call once complete. pub(crate) duration: Option, + + /// User-provided input associated with a unified exec interaction. pub(crate) interaction_input: Option, } +/// Groups one or more exec calls into a single transcript cell. +/// +/// Cells are appended to the transcript in order, but exploratory calls may be +/// merged to reduce clutter. The cell tracks whether the UI should animate +/// active commands during rendering. #[derive(Debug)] pub(crate) struct ExecCell { + /// Ordered list of exec calls belonging to this cell. pub(crate) calls: Vec, + + /// Whether the UI should render animated progress for in-flight calls. animations_enabled: bool, } impl ExecCell { + /// Create a new exec cell with a single call. + /// + /// The initial call is treated as in-flight and will be completed via + /// [`ExecCell::complete_call`] once output arrives. pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self { Self { calls: vec![call], @@ -39,6 +93,9 @@ impl ExecCell { } } + /// Returns a new cell with `call` appended when both calls are "exploring". + /// + /// This allows consecutive exploratory commands to render as a single transcript block. pub(crate) fn with_added_call( &self, call_id: String, @@ -67,6 +124,10 @@ impl ExecCell { } } + /// Mark a call as complete with its final output and duration. + /// + /// The most recent matching call is updated to handle repeated call IDs in + /// merged exploratory cells. pub(crate) fn complete_call( &mut self, call_id: &str, @@ -80,10 +141,18 @@ impl ExecCell { } } + /// Returns true once the cell is complete and safe to flush to history. + /// + /// Exploratory cells stay in the live view until they are merged into a + /// non-exploring cell by the caller. pub(crate) fn should_flush(&self) -> bool { !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) } + /// Mark any in-flight calls as failed with empty output. + /// + /// This is used when the exec stream ends unexpectedly so the transcript + /// does not leave hanging in-flight rows. pub(crate) fn mark_failed(&mut self) { for call in self.calls.iter_mut() { if call.output.is_none() { @@ -102,14 +171,17 @@ impl ExecCell { } } + /// Returns true if all calls in the cell are "exploring". pub(crate) fn is_exploring_cell(&self) -> bool { self.calls.iter().all(Self::is_exploring_call) } + /// Returns true if any call in the cell is still running. pub(crate) fn is_active(&self) -> bool { self.calls.iter().any(|c| c.output.is_none()) } + /// Returns the start time of the first still-running call, if any. pub(crate) fn active_start_time(&self) -> Option { self.calls .iter() @@ -117,14 +189,20 @@ impl ExecCell { .and_then(|c| c.start_time) } + /// Returns whether the cell should animate while rendering. pub(crate) fn animations_enabled(&self) -> bool { self.animations_enabled } + /// Iterate over calls in chronological order. pub(crate) fn iter_calls(&self) -> impl Iterator { self.calls.iter() } + /// Append a raw output chunk to the matching call's aggregated output. + /// + /// Returns true when the chunk was appended to an active call, or false if the call is not + /// found or the chunk is empty. pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool { if chunk.is_empty() { return false; @@ -137,6 +215,7 @@ impl ExecCell { true } + /// Returns true if the call represents a non-user exploratory read/list/search. pub(super) fn is_exploring_call(call: &ExecCall) -> bool { !matches!(call.source, ExecCommandSource::UserShell) && !call.parsed.is_empty() @@ -152,10 +231,12 @@ impl ExecCell { } impl ExecCall { + /// Returns true when the call originated from a direct user shell command. pub(crate) fn is_user_shell_command(&self) -> bool { matches!(self.source, ExecCommandSource::UserShell) } + /// Returns true when the call is a unified exec interaction prompt. pub(crate) fn is_unified_exec_interaction(&self) -> bool { matches!(self.source, ExecCommandSource::UnifiedExecInteraction) } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 6517bcf470..cb142262af 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -1,3 +1,12 @@ +//! Rendering helpers for exec history cells. +//! +//! This module turns [`ExecCell`] state into formatted `ratatui` lines for the transcript and +//! compact display rows. It handles command highlighting, output truncation, and the special +//! "exploring" presentation used for read/list/search tool calls. +//! +//! The rendering logic is careful to apply line limits to wrapped screen lines (not just logical +//! lines) so long outputs cannot flood the viewport. + use std::time::Instant; use super::model::CommandOutput; @@ -24,17 +33,31 @@ use ratatui::style::Stylize; use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; +/// Maximum number of output lines shown for non-user tool calls. pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; + +/// Maximum number of output lines shown for user shell commands. const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; + +/// Maximum number of characters shown when previewing interaction input. const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; +/// Controls how command output is summarized into display lines. pub(crate) struct OutputLinesParams { + /// Maximum number of head/tail lines to include. pub(crate) line_limit: usize, + + /// Whether to suppress output when the command succeeded. pub(crate) only_err: bool, + + /// Whether to draw the angle pipe marker for the first line. pub(crate) include_angle_pipe: bool, + + /// Whether to prefix lines with the standard indent. pub(crate) include_prefix: bool, } +/// Construct a new exec cell for a just-started command. pub(crate) fn new_active_exec_command( call_id: String, command: Vec, @@ -58,6 +81,7 @@ pub(crate) fn new_active_exec_command( ) } +/// Format the summary line for a unified exec interaction call. fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { let command_display = if let Some((_, script)) = extract_bash_command(command) { script.to_string() @@ -73,6 +97,7 @@ fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> S } } +/// Collapse interaction input into a short, escaped preview string. fn summarize_interaction_input(input: &str) -> String { let single_line = input.replace('\n', "\\n"); let sanitized = single_line.replace('`', "\\`"); @@ -88,12 +113,17 @@ fn summarize_interaction_input(input: &str) -> String { preview } +/// Result of formatting command output into lines. #[derive(Clone)] pub(crate) struct OutputLines { + /// Styled output lines to render. pub(crate) lines: Vec>, + + /// Number of omitted logical lines, if truncation occurred. pub(crate) omitted: Option, } +/// Convert command output into displayable lines with optional truncation. pub(crate) fn output_lines( output: Option<&CommandOutput>, params: OutputLinesParams, @@ -177,6 +207,7 @@ pub(crate) fn output_lines( } } +/// Returns a spinner glyph for active commands, respecting animation settings. pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { if !animations_enabled { return "•".dim(); @@ -194,6 +225,7 @@ pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> } impl HistoryCell for ExecCell { + /// Render the compact display rows for the exec cell. fn display_lines(&self, width: u16) -> Vec> { if self.is_exploring_cell() { self.exploring_display_lines(width) @@ -202,10 +234,12 @@ impl HistoryCell for ExecCell { } } + /// Return the desired transcript height for this exec cell. fn desired_transcript_height(&self, width: u16) -> u16 { self.transcript_lines(width).len() as u16 } + /// Render the transcript lines for the exec cell. fn transcript_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { @@ -252,6 +286,7 @@ impl HistoryCell for ExecCell { } impl ExecCell { + /// Render the compact display for exploratory read/list/search calls. fn exploring_display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); out.push(Line::from(vec![ @@ -355,6 +390,7 @@ impl ExecCell { out } + /// Render the compact display for a single non-exploring command. fn command_display_lines(&self, width: u16) -> Vec> { let [call] = &self.calls.as_slice() else { panic!("Expected exactly one call in a command display cell"); @@ -491,6 +527,7 @@ impl ExecCell { lines } + /// Keep the first `keep` lines and append an ellipsis line when truncated. fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { if lines.len() <= keep { return lines.to_vec(); @@ -504,6 +541,7 @@ impl ExecCell { out } + /// Truncate lines in the middle, preserving head/tail context and an ellipsis count. fn truncate_lines_middle( lines: &[Line<'static>], max: usize, @@ -551,18 +589,24 @@ impl ExecCell { out } + /// Format an ellipsis line showing how many lines were omitted. fn ellipsis_line(omitted: usize) -> Line<'static> { Line::from(vec![format!("… +{omitted} lines").dim()]) } } +/// Layout configuration for blocks that render with a leading prefix. #[derive(Clone, Copy)] struct PrefixedBlock { + /// Prefix for the first line in the block. initial_prefix: &'static str, + + /// Prefix for subsequent lines in the block. subsequent_prefix: &'static str, } impl PrefixedBlock { + /// Create a prefixed block definition. const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self { Self { initial_prefix, @@ -570,6 +614,7 @@ impl PrefixedBlock { } } + /// Compute the available wrap width after accounting for prefixes. fn wrap_width(self, total_width: u16) -> usize { let prefix_width = UnicodeWidthStr::width(self.initial_prefix) .max(UnicodeWidthStr::width(self.subsequent_prefix)); @@ -577,15 +622,24 @@ impl PrefixedBlock { } } +/// Layout constants for command display formatting. #[derive(Clone, Copy)] struct ExecDisplayLayout { + /// Prefix settings for wrapped command continuations. command_continuation: PrefixedBlock, + + /// Maximum number of continuation lines to show. command_continuation_max_lines: usize, + + /// Prefix settings for output blocks. output_block: PrefixedBlock, + + /// Maximum number of output lines to show. output_max_lines: usize, } impl ExecDisplayLayout { + /// Create a display layout with the provided prefixes and limits. const fn new( command_continuation: PrefixedBlock, command_continuation_max_lines: usize, @@ -601,6 +655,7 @@ impl ExecDisplayLayout { } } +/// Default layout for compact exec display. const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( PrefixedBlock::new(" │ ", " │ "), 2, diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 8ce6c2632e..08826481fb 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -1,3 +1,8 @@ +//! Helpers for formatting and normalizing exec command strings. +//! +//! These functions convert argv vectors into shell-friendly display strings and provide path +//! helpers for making output more readable in the UI. + use std::path::Path; use std::path::PathBuf; @@ -5,10 +10,12 @@ use codex_core::parse_command::extract_shell_command; use dirs::home_dir; use shlex::try_join; +/// Join argv into a shell-escaped command line for display. pub(crate) fn escape_command(command: &[String]) -> String { try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) } +/// Strip `bash -lc`/`zsh -lc` wrappers when possible, otherwise escape the argv. pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { if let Some((_, script)) = extract_shell_command(command) { return script.to_string(); @@ -18,7 +25,7 @@ pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { /// If `path` is absolute and inside $HOME, return the part *after* the home /// directory; otherwise, return the path as-is. Note if `path` is the homedir, -/// this will return and empty path. +/// this will return an empty path. pub(crate) fn relativize_to_home

(path: P) -> Option where P: AsRef, diff --git a/codex-rs/tui/src/external_editor.rs b/codex-rs/tui/src/external_editor.rs index 2818503d03..82aa60cde7 100644 --- a/codex-rs/tui/src/external_editor.rs +++ b/codex-rs/tui/src/external_editor.rs @@ -1,3 +1,8 @@ +//! External editor integration for editing long prompts. +//! +//! This module resolves the editor command from environment variables, opens a temporary file, +//! and returns the edited contents once the editor exits. + use std::env; use std::fs; use std::process::Stdio; @@ -8,13 +13,17 @@ use tempfile::Builder; use thiserror::Error; use tokio::process::Command; +/// Errors that can occur while resolving or launching the editor command. #[derive(Debug, Error)] pub(crate) enum EditorError { + /// Neither `VISUAL` nor `EDITOR` is set in the environment. #[error("neither VISUAL nor EDITOR is set")] MissingEditor, #[cfg(not(windows))] + /// The editor command could not be parsed into arguments. #[error("failed to parse editor command")] ParseFailed, + /// The resolved editor command was empty. #[error("editor command is empty")] EmptyCommand, } @@ -51,6 +60,9 @@ pub(crate) fn resolve_editor_command() -> std::result::Result, Edito } /// Write `seed` to a temp file, launch the editor command, and return the updated content. +/// +/// The temp file is created with a `.md` suffix and is persisted after the editor exits so the +/// contents can be read back on all platforms. pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result { if editor_cmd.is_empty() { return Err(Report::msg("editor command is empty")); diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index af46512640..6f9a07df12 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -31,13 +31,16 @@ use std::time::Duration; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +/// Maximum number of file search results to return per query. const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap(); +/// Number of worker threads used by the file search backend. const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); /// How long to wait after a keystroke before firing the first search when none /// is currently running. Keeps early queries more meaningful. const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); +/// Poll interval used to wait for the active search to finish after debounce. const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); /// State machine for file-search orchestration. @@ -45,10 +48,13 @@ pub(crate) struct FileSearchManager { /// Unified state guarded by one mutex. state: Arc>, + /// Root directory to search within. search_dir: PathBuf, + /// App event sender used to deliver results. app_tx: AppEventSender, } +/// Shared mutable state for debounced file searches. struct SearchState { /// Latest query typed by user (updated every keystroke). latest_query: String, @@ -60,12 +66,16 @@ struct SearchState { active_search: Option, } +/// Tracks a running search and its cancellation token. struct ActiveSearch { + /// Query string being searched. query: String, + /// Cancellation token polled by the search worker. cancellation_token: Arc, } impl FileSearchManager { + /// Create a new file search manager rooted at `search_dir`. pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { Self { state: Arc::new(Mutex::new(SearchState { @@ -155,6 +165,7 @@ impl FileSearchManager { }); } + /// Spawn the file search worker and deliver results back to the UI. fn spawn_file_search( query: String, search_dir: PathBuf, diff --git a/codex-rs/tui/src/frames.rs b/codex-rs/tui/src/frames.rs index 19a70578d4..d3822cfd3c 100644 --- a/codex-rs/tui/src/frames.rs +++ b/codex-rs/tui/src/frames.rs @@ -1,6 +1,16 @@ +//! Embedded ASCII animation frames for the TUI loading indicator. +//! +//! Each variant is stored as a fixed set of 36 text frames included at compile time, so the +//! runtime never touches the filesystem when animating. The module exposes the per-variant +//! frame arrays, the ordered list of variants, and the default tick duration that drives the +//! animation cadence. + use std::time::Duration; -// Embed animation frames for each variant at compile time. +/// Expands to the 36 frame strings for a specific frame directory. +/// +/// This macro is used to include fixed-size animation sequences at compile time so that the +/// spinner implementation can index into the frames without doing any runtime IO. macro_rules! frames_for { ($dir:literal) => { [ @@ -44,17 +54,30 @@ macro_rules! frames_for { }; } +/// Default spinner frames. pub(crate) const FRAMES_DEFAULT: [&str; 36] = frames_for!("default"); +/// Codex-branded spinner frames. pub(crate) const FRAMES_CODEX: [&str; 36] = frames_for!("codex"); +/// OpenAI-branded spinner frames. pub(crate) const FRAMES_OPENAI: [&str; 36] = frames_for!("openai"); +/// Block-style spinner frames. pub(crate) const FRAMES_BLOCKS: [&str; 36] = frames_for!("blocks"); +/// Dot-style spinner frames. pub(crate) const FRAMES_DOTS: [&str; 36] = frames_for!("dots"); +/// Hash-style spinner frames. pub(crate) const FRAMES_HASH: [&str; 36] = frames_for!("hash"); +/// Horizontal bar spinner frames. pub(crate) const FRAMES_HBARS: [&str; 36] = frames_for!("hbars"); +/// Vertical bar spinner frames. pub(crate) const FRAMES_VBARS: [&str; 36] = frames_for!("vbars"); +/// Shape-based spinner frames. pub(crate) const FRAMES_SHAPES: [&str; 36] = frames_for!("shapes"); +/// Slug animation frames. pub(crate) const FRAMES_SLUG: [&str; 36] = frames_for!("slug"); +/// Ordered list of all available frame variants. +/// +/// The order is stable so callers can treat indices as persistent choices. pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ &FRAMES_DEFAULT, &FRAMES_CODEX, @@ -68,4 +91,7 @@ pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ &FRAMES_SLUG, ]; +/// Default frame tick duration used by the animation driver. +/// +/// This is tuned for readable cadence across the bundled 36-frame sequences. pub(crate) const FRAME_TICK_DEFAULT: Duration = Duration::from_millis(80); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c060d20cd9..e046799fa0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -71,12 +71,17 @@ use tracing::error; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. +/// Represents a unit of conversation history that can render itself for the UI. +/// +/// Each cell owns the data needed to render a discrete event in the chat +/// transcript. The TUI renders these cells into a scrolling list and may ask +/// for alternate transcript output (for the overlay) or a time-based animation +/// tick when a cell's visual output depends on elapsed time. pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { + /// Returns the lines to render in the main chat viewport for the given width. fn display_lines(&self, width: u16) -> Vec>; + /// Returns the wrapped height of `display_lines` for layout decisions. fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) @@ -85,10 +90,15 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { .unwrap_or(0) } + /// Returns the lines to render in the transcript overlay for the given width. + /// + /// The default mirrors `display_lines`, but cells can override this to omit + /// view-only content or adjust formatting for the transcript. fn transcript_lines(&self, width: u16) -> Vec> { self.display_lines(width) } + /// Returns the wrapped height of `transcript_lines` for transcript layout. fn desired_transcript_height(&self, width: u16) -> u16 { let lines = self.transcript_lines(width); // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. @@ -108,6 +118,7 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { .unwrap_or(0) } + /// Returns whether this cell continues a streaming message from the previous line. fn is_stream_continuation(&self) -> bool { false } @@ -128,6 +139,7 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { } impl Renderable for Box { + /// Renders the history cell by scrolling to the visible tail when needed. fn render(&self, area: Rect, buf: &mut Buffer) { let lines = self.display_lines(area.width); let y = if area.height == 0 { @@ -140,23 +152,29 @@ impl Renderable for Box { .scroll((y, 0)) .render(area, buf); } + + /// Returns the desired height using the underlying `HistoryCell` implementation. fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) } } impl dyn HistoryCell { + /// Downcasts the cell to `Any` for type-erased inspection. pub(crate) fn as_any(&self) -> &dyn Any { self } + /// Downcasts the cell to `Any` for mutable type-erased inspection. pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { self } } +/// Renders a user-authored message with the live-prefix styling. #[derive(Debug)] pub(crate) struct UserHistoryCell { + /// Raw user-entered content to render in the transcript. pub message: String, } @@ -186,14 +204,19 @@ impl HistoryCell for UserHistoryCell { } } +/// Renders a condensed, dimmed summary of a reasoning block. #[derive(Debug)] pub(crate) struct ReasoningSummaryCell { + /// Markdown header extracted from a reasoning summary, if present. _header: String, + /// Summary content rendered as dim, italicized markdown. content: String, + /// Whether the cell should render only in transcript view. transcript_only: bool, } impl ReasoningSummaryCell { + /// Creates a reasoning summary cell from its parsed header/content. pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { Self { _header: header, @@ -202,6 +225,7 @@ impl ReasoningSummaryCell { } } + /// Formats the summary as dim, wrapped markdown lines. fn lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); append_markdown( @@ -257,13 +281,17 @@ impl HistoryCell for ReasoningSummaryCell { } } +/// Wraps assistant message lines and applies the assistant bullet prefix. #[derive(Debug)] pub(crate) struct AgentMessageCell { + /// Preformatted message lines before wrapping and prefixing. lines: Vec>, + /// Whether this is the first line of a streamed assistant response. is_first_line: bool, } impl AgentMessageCell { + /// Creates a wrapped assistant message cell with a first-line flag. pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { Self { lines, @@ -291,12 +319,15 @@ impl HistoryCell for AgentMessageCell { } } +/// Re-emits preformatted lines with no additional wrapping. #[derive(Debug)] pub(crate) struct PlainHistoryCell { + /// Lines to render verbatim in the history list. lines: Vec>, } impl PlainHistoryCell { + /// Creates a history cell that reuses the provided lines verbatim. pub(crate) fn new(lines: Vec>) -> Self { Self { lines } } @@ -308,15 +339,19 @@ impl HistoryCell for PlainHistoryCell { } } +/// Displays the "update available" notice when a newer CLI is detected. #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { + /// Latest version string discovered by the update checker. latest_version: String, + /// Optional upgrade action to show in the hint line. update_action: Option, } #[cfg_attr(debug_assertions, allow(dead_code))] impl UpdateAvailableHistoryCell { + /// Creates an update notice with optional CLI upgrade guidance. pub(crate) fn new(latest_version: String, update_action: Option) -> Self { Self { latest_version, @@ -362,14 +397,19 @@ impl HistoryCell for UpdateAvailableHistoryCell { } } +/// Wraps `Text` and prefixes its wrapped lines with custom per-line prefixes. #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { + /// Text payload to wrap into display lines. text: Text<'static>, + /// Prefix used for the first wrapped line. initial_prefix: Line<'static>, + /// Prefix used for subsequent wrapped lines. subsequent_prefix: Line<'static>, } impl PrefixedWrappedHistoryCell { + /// Builds a prefixed wrapper that controls first vs subsequent indents. pub(crate) fn new( text: impl Into>, initial_prefix: impl Into>, @@ -402,13 +442,17 @@ impl HistoryCell for PrefixedWrappedHistoryCell { } } +/// Summarizes a background terminal interaction for the history transcript. #[derive(Debug)] pub(crate) struct UnifiedExecInteractionCell { + /// Optional command summary shown in the header line. command_display: Option, + /// Raw stdin content sent to the interactive process. stdin: String, } impl UnifiedExecInteractionCell { + /// Creates a cell summarizing a background terminal interaction. pub(crate) fn new(command_display: Option, stdin: String) -> Self { Self { command_display, @@ -461,6 +505,7 @@ impl HistoryCell for UnifiedExecInteractionCell { } } +/// Builds a history cell describing a background terminal interaction. pub(crate) fn new_unified_exec_interaction( command_display: Option, stdin: String, @@ -468,12 +513,15 @@ pub(crate) fn new_unified_exec_interaction( UnifiedExecInteractionCell::new(command_display, stdin) } +/// Lists active background terminals for the `/ps` command output. #[derive(Debug)] struct UnifiedExecProcessesCell { + /// Command strings for each tracked background process. processes: Vec, } impl UnifiedExecProcessesCell { + /// Creates a processes cell for the `/ps` output. fn new(processes: Vec) -> Self { Self { processes } } @@ -562,12 +610,14 @@ impl HistoryCell for UnifiedExecProcessesCell { } } +/// Creates the `/ps` output cell with a command header and process list. pub(crate) fn new_unified_exec_processes_output(processes: Vec) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); let summary = UnifiedExecProcessesCell::new(processes); CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) } +/// Truncates a full command into a short, single-line snippet. fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), @@ -577,11 +627,13 @@ fn truncate_exec_snippet(full_cmd: &str) -> String { snippet } +/// Returns a display-friendly snippet for a shell command vector. fn exec_snippet(command: &[String]) -> String { let full_cmd = strip_bash_lc_and_escape(command); truncate_exec_snippet(&full_cmd) } +/// Builds the approval decision line for exec review events. pub fn new_approval_decision_cell( command: Vec, decision: codex_core::protocol::ReviewDecision, @@ -668,9 +720,12 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { } } +/// Renders a diff summary for a pending patch or patch application. #[derive(Debug)] pub(crate) struct PatchHistoryCell { + /// Map of file changes keyed by path. changes: HashMap, + /// Working directory used for display path relativization. cwd: PathBuf, } @@ -680,8 +735,10 @@ impl HistoryCell for PatchHistoryCell { } } +/// Marker cell used when an MCP tool returns image output. #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { + /// Decoded image payload for future image-specific rendering. _image: DynamicImage, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { @@ -690,8 +747,10 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } } +/// Upper bound for the session header card's inner width. pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value +/// Computes the inner content width for a bordered card, if space allows. pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { if width < 4 { return None; @@ -717,6 +776,7 @@ pub(crate) fn with_border_with_inner_width( with_border_internal(lines, Some(inner_width)) } +/// Computes the padded bordered output for a set of lines. fn with_border_internal( lines: Vec>, forced_inner_width: Option, @@ -766,12 +826,15 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } +/// Renders a short tooltip snippet in the transcript. #[derive(Debug)] struct TooltipHistoryCell { + /// Tooltip message already formatted as markdown. tip: String, } impl TooltipHistoryCell { + /// Wraps a tooltip string for display. fn new(tip: String) -> Self { Self { tip } } @@ -795,6 +858,7 @@ impl HistoryCell for TooltipHistoryCell { } } +/// Wraps the session header and optional follow-up lines into one cell. #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); @@ -812,6 +876,7 @@ impl HistoryCell for SessionInfoCell { } } +/// Builds the session header cell and associated helper text. pub(crate) fn new_session_info( config: &Config, requested_model: &str, @@ -886,20 +951,28 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } +/// Creates a new user-entered message cell. pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { UserHistoryCell { message } } +/// Renders the top-of-session header card shown in the transcript. #[derive(Debug)] pub(crate) struct SessionHeaderHistoryCell { + /// CLI version string shown in the header title line. version: &'static str, + /// Model name presented in the header. model: String, + /// Style override for the model name. model_style: Style, + /// Reasoning effort label, when the model supports it. reasoning_effort: Option, + /// Working directory displayed under the model line. directory: PathBuf, } impl SessionHeaderHistoryCell { + /// Creates a header cell with default model styling. pub(crate) fn new( model: String, reasoning_effort: Option, @@ -915,6 +988,7 @@ impl SessionHeaderHistoryCell { ) } + /// Creates a header cell that applies a custom style to the model name. pub(crate) fn new_with_style( model: String, model_style: Style, @@ -931,10 +1005,12 @@ impl SessionHeaderHistoryCell { } } + /// Formats the session directory, optionally truncating to fit a width. fn format_directory(&self, max_width: Option) -> String { Self::format_directory_inner(&self.directory, max_width) } + /// Formats the directory path using a `~` home prefix and truncation. fn format_directory_inner(directory: &Path, max_width: Option) -> String { let formatted = if let Some(rel) = relativize_to_home(directory) { if rel.as_os_str().is_empty() { @@ -958,6 +1034,7 @@ impl SessionHeaderHistoryCell { formatted } + /// Maps reasoning effort config into a human-readable label. fn reasoning_label(&self) -> Option<&'static str> { self.reasoning_effort.map(|effort| match effort { ReasoningEffortConfig::Minimal => "minimal", @@ -1026,12 +1103,15 @@ impl HistoryCell for SessionHeaderHistoryCell { } } +/// Renders multiple history cells as a single composite entry. #[derive(Debug)] pub(crate) struct CompositeHistoryCell { + /// Ordered child cells that make up this composite entry. parts: Vec>, } impl CompositeHistoryCell { + /// Creates a composite history entry from the provided child cells. pub(crate) fn new(parts: Vec>) -> Self { Self { parts } } @@ -1055,17 +1135,25 @@ impl HistoryCell for CompositeHistoryCell { } } +/// Renders an MCP tool call and its eventual output or error. #[derive(Debug)] pub(crate) struct McpToolCallCell { + /// Unique call identifier used for correlation and completion. call_id: String, + /// Tool invocation metadata (server, tool, arguments). invocation: McpInvocation, + /// Start time used for elapsed-time display and animation ticks. start_time: Instant, + /// Total duration once the call completes. duration: Option, + /// Tool output or failure message, once available. result: Option>, + /// Whether time-based animations are enabled in the UI. animations_enabled: bool, } impl McpToolCallCell { + /// Creates an in-flight MCP tool call cell. pub(crate) fn new( call_id: String, invocation: McpInvocation, @@ -1081,10 +1169,12 @@ impl McpToolCallCell { } } + /// Returns the stable call identifier for matching updates. pub(crate) fn call_id(&self) -> &str { &self.call_id } + /// Marks the tool call as complete and returns an optional image cell. pub(crate) fn complete( &mut self, duration: Duration, @@ -1097,6 +1187,7 @@ impl McpToolCallCell { image_cell } + /// Returns success state once the tool call has completed. fn success(&self) -> Option { match self.result.as_ref() { Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), @@ -1105,12 +1196,14 @@ impl McpToolCallCell { } } + /// Records an interrupted call result using the elapsed time so far. pub(crate) fn mark_failed(&mut self) { let elapsed = self.start_time.elapsed(); self.duration = Some(elapsed); self.result = Some(Err("interrupted".to_string())); } + /// Formats a content block into a displayable string snippet. fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { match block { mcp_types::ContentBlock::TextContent(text) => { @@ -1231,6 +1324,7 @@ impl HistoryCell for McpToolCallCell { } } +/// Creates an in-flight MCP tool call cell that animates while running. pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, @@ -1239,6 +1333,7 @@ pub(crate) fn new_active_mcp_tool_call( McpToolCallCell::new(call_id, invocation, animations_enabled) } +/// Renders a "searched" history cell for web tool usage. pub(crate) fn new_web_search_call(query: String) -> PrefixedWrappedHistoryCell { let text: Text<'static> = Line::from(vec!["Searched".bold(), " ".into(), query.into()]).into(); PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ") @@ -1284,17 +1379,22 @@ fn try_new_completed_mcp_tool_call_with_image_output( } } +/// Renders a yellow warning line in the transcript. #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ") } +/// Renders a structured deprecation warning with an optional detail line. #[derive(Debug)] pub(crate) struct DeprecationNoticeCell { + /// Short summary of the deprecation. summary: String, + /// Optional follow-up detail describing what to do next. details: Option, } +/// Creates a deprecation notice cell for the transcript. pub(crate) fn new_deprecation_notice( summary: String, details: Option, @@ -1505,6 +1605,8 @@ pub(crate) fn new_mcp_tools_output( PlainHistoryCell { lines } } + +/// Renders an informational event with an optional dim hint. pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { @@ -1515,6 +1617,7 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist PlainHistoryCell { lines } } +/// Renders a red error line for a user-facing event. pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter @@ -1529,9 +1632,12 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { PlanUpdateCell { explanation, plan } } +/// Renders a plan update with optional explanation and step statuses. #[derive(Debug)] pub(crate) struct PlanUpdateCell { + /// Optional explanation string shown above the steps. explanation: Option, + /// Ordered plan items to display as a checklist. plan: Vec, } @@ -1602,6 +1708,7 @@ pub(crate) fn new_patch_event( } } +/// Renders the stderr from a failed patch apply as a history cell. pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { let mut lines: Vec> = Vec::new(); @@ -1628,6 +1735,7 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +/// Renders the history entry for an image view tool call. pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell { let display_path = display_path_for(&path, cwd); @@ -1639,6 +1747,7 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor PlainHistoryCell { lines } } +/// Parses a reasoning buffer into a summary cell with optional header stripping. pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box { let full_reasoning_buffer = full_reasoning_buffer.trim(); if let Some(open) = full_reasoning_buffer.find("**") { @@ -1665,13 +1774,14 @@ pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box< )) } -#[derive(Debug)] /// A visual divider between turns, optionally showing how long the assistant "worked for". /// /// This separator is only emitted for turns that performed concrete work (e.g., running commands, /// applying patches, making MCP tool calls), so purely conversational turns do not show an empty /// divider. +#[derive(Debug)] pub struct FinalMessageSeparator { + /// Total elapsed time for the completed turn, when tracked. elapsed_seconds: Option, } impl FinalMessageSeparator { @@ -1701,6 +1811,7 @@ impl HistoryCell for FinalMessageSeparator { } } +/// Formats an MCP tool invocation into a compact `server.tool(args)` line. fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { let args_str = invocation .arguments diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 36ef47da5e..675aaa8362 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,3 +1,14 @@ +//! Inserts transcript history lines directly into terminal scrollback. +//! +//! The TUI keeps a viewport at the bottom of the screen, but on some terminals it is possible to +//! extend scrollback by inserting lines above the viewport using ANSI scroll regions. This module +//! implements that behavior with crossterm commands, keeping the cursor position stable and +//! reusing the same wrapping rules as the on-screen TUI so scrollback mirrors the viewport. +//! +//! The implementation assumes ANSI scroll-region support (DECSTBM) and Reverse Index (RI, `ESC M`) +//! are available. If the viewport is not at the screen bottom, it is shifted downward to make room +//! for the inserted lines, and the viewport area is updated accordingly. + use std::fmt; use std::io; use std::io::Write; @@ -22,8 +33,12 @@ use ratatui::style::Modifier; use ratatui::text::Line; use ratatui::text::Span; -/// Insert `lines` above the viewport using the terminal's backend writer -/// (avoids direct stdout references). +/// Inserts `lines` above the viewport using the terminal's backend writer. +/// +/// This uses scroll regions to insert wrapped lines into terminal scrollback without touching +/// stdout directly. The cursor position is restored before returning. If the viewport is not at +/// the screen bottom, it is scrolled downward (up to the screen edge) and the terminal's stored +/// viewport area is updated to match the shift. pub fn insert_history_lines( terminal: &mut crate::custom_terminal::Terminal, lines: Vec, @@ -108,6 +123,7 @@ where )) )?; queue!(writer, Clear(ClearType::UntilNewLine))?; + // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line @@ -134,6 +150,7 @@ where Ok(()) } +/// Crossterm command to set the scroll region (DECSTBM) in 1-based rows. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SetScrollRegion(pub std::ops::Range); @@ -154,6 +171,7 @@ impl Command for SetScrollRegion { } } +/// Crossterm command to reset the scroll region to the full screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ResetScrollRegion; @@ -174,12 +192,16 @@ impl Command for ResetScrollRegion { } } +/// Describes a modifier transition so we can emit minimal ANSI updates. struct ModifierDiff { + /// Previously active modifier set. pub from: Modifier, + /// Next modifier set to apply. pub to: Modifier, } impl ModifierDiff { + /// Queues ANSI updates for modifier differences on the provided writer. fn queue(self, mut w: W) -> io::Result<()> where W: io::Write, @@ -241,6 +263,7 @@ impl ModifierDiff { } } +/// Writes spans with correct color and modifier transitions, then resets styles. fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> where I: IntoIterator>, @@ -325,6 +348,7 @@ mod tests { let height: u16 = 10; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + // Place viewport on the last line so history inserts scroll upward let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); @@ -360,6 +384,7 @@ mod tests { let height: u16 = 8; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + // Viewport is the last line so history goes directly above it. let viewport = Rect::new(0, height - 1, width, 1); term.set_viewport_area(viewport); diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f277f07384..b3fd996180 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -1,3 +1,8 @@ +//! Utilities for formatting key bindings into UI hint strings. +//! +//! The helpers here normalize modifier display across platforms and convert key bindings into +//! stylized `Span` values for footer hints and menus. + use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -7,25 +12,34 @@ use ratatui::style::Stylize; use ratatui::text::Span; #[cfg(test)] +/// Prefix used for Alt-modified key hints on macOS and in tests. const ALT_PREFIX: &str = "⌥ + "; #[cfg(all(not(test), target_os = "macos"))] +/// Prefix used for Alt-modified key hints on macOS. const ALT_PREFIX: &str = "⌥ + "; #[cfg(all(not(test), not(target_os = "macos")))] +/// Prefix used for Alt-modified key hints on non-macOS platforms. const ALT_PREFIX: &str = "alt + "; +/// Prefix used for Ctrl-modified key hints. const CTRL_PREFIX: &str = "ctrl + "; +/// Prefix used for Shift-modified key hints. const SHIFT_PREFIX: &str = "shift + "; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct KeyBinding { + /// Base key for the binding. key: KeyCode, + /// Modifier set required for the binding. modifiers: KeyModifiers, } impl KeyBinding { + /// Build a key binding from a key code and modifier set. pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self { Self { key, modifiers } } + /// Return true when the event matches this binding and is a press/repeat. pub fn is_press(&self, event: KeyEvent) -> bool { self.key == event.code && self.modifiers == event.modifiers @@ -33,26 +47,32 @@ impl KeyBinding { } } +/// Construct a plain (no-modifier) key binding. pub(crate) const fn plain(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::NONE) } +/// Construct an Alt-modified key binding. pub(crate) const fn alt(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::ALT) } +/// Construct a Shift-modified key binding. pub(crate) const fn shift(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::SHIFT) } +/// Construct a Ctrl-modified key binding. pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::CONTROL) } +/// Construct a Ctrl+Alt-modified key binding. pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT)) } +/// Convert modifiers to a human-friendly prefix string. fn modifiers_to_string(modifiers: KeyModifiers) -> String { let mut result = String::new(); if modifiers.contains(KeyModifiers::CONTROL) { @@ -68,11 +88,13 @@ fn modifiers_to_string(modifiers: KeyModifiers) -> String { } impl From for Span<'static> { + /// Convert a binding into a styled span for display. fn from(binding: KeyBinding) -> Self { (&binding).into() } } impl From<&KeyBinding> for Span<'static> { + /// Convert a binding reference into a styled span for display. fn from(binding: &KeyBinding) -> Self { let KeyBinding { key, modifiers } = binding; let modifiers = modifiers_to_string(*modifiers); @@ -91,22 +113,26 @@ impl From<&KeyBinding> for Span<'static> { } } +/// Return the default key hint style (dimmed). fn key_hint_style() -> Style { Style::default().dim() } +/// Return true when Ctrl or Alt are present, excluding AltGr. pub(crate) fn has_ctrl_or_alt(mods: KeyModifiers) -> bool { (mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods) } #[cfg(windows)] #[inline] +/// Return true if the modifier set represents AltGr on Windows. pub(crate) fn is_altgr(mods: KeyModifiers) -> bool { mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL) } #[cfg(not(windows))] #[inline] +/// Return true if the modifier set represents AltGr (always false off Windows). pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool { false } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 880ff19322..accf21733f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1,3 +1,20 @@ +//! Terminal UI implementation for Codex. +//! +//! This crate owns the on-screen state machine for the interactive client. It +//! translates user input and protocol events into ratatui frames, manages +//! terminal modes such as the alternate screen, and coordinates background work +//! via an internal event channel. +//! +//! Core behavior (thread state, auth, config, models, protocol) lives in +//! `codex-core` and `codex-protocol`. This crate is intentionally a rendering +//! and input orchestration layer: it owns transient UI state, but it does not +//! persist or mutate the underlying session data beyond what `codex-core` +//! exposes through its APIs. The [`run_main`] entrypoint wires those layers +//! together and returns [`AppExitInfo`] describing why the UI shut down. +//! +//! The library portion forbids stdout/stderr writes so the standalone binary can +//! manage terminal modes without unexpected output. + // Forbid accidental stdout/stderr writes in the *library* portion of the TUI. // The standalone `codex-tui` binary prints a short help message before the // alternate‑screen mode starts; that file opts‑out locally via `allow`. @@ -5,7 +22,9 @@ #![deny(clippy::disallowed_methods)] use additional_dirs::add_dir_warning_message; use app::App; +/// Summary of how and why the TUI shut down. pub use app::AppExitInfo; +/// High-level reason for shutdown returned alongside [`AppExitInfo`]. pub use app::ExitReason; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; @@ -98,16 +117,33 @@ use crate::onboarding::TrustDirectorySelection; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; +/// Parsed CLI configuration for launching the TUI. pub use cli::Cli; +/// Renders markdown into styled text for TUI display. pub use markdown_render::render_markdown_text; +/// Actions emitted by the public composer input widget. pub use public_widgets::composer_input::ComposerAction; +/// Public composer input widget used by external renderers. pub use public_widgets::composer_input::ComposerInput; + // (tests access modules directly within the crate) +/// Runs the TUI with the provided CLI options and returns the shutdown summary. +/// +/// The setup phase resolves configuration, auth, and model provider defaults, +/// then boots the [`App`] event loop against a configured [`Tui`]. On exit, the +/// function returns [`AppExitInfo`] describing the shutdown path and any final +/// session metadata for the caller to inspect. +/// +/// Phases: +/// 1. Derive CLI overrides and load configuration (including OSS provider/model selection). +/// 2. Initialize logging and telemetry. +/// 3. Launch the ratatui event loop and return the exit summary. pub async fn run_main( mut cli: Cli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { + // Phase 1: derive CLI overrides and resolve configuration. let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -133,8 +169,10 @@ pub async fn run_main( } // When using `--oss`, let the bootstrapper pick the model (defaulting to - // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in + // gpt-oss:20b) and ensure it is present locally. We still parse CLI + // overrides so non-model config values can be merged in. let raw_overrides = cli.config_overrides.raw_overrides.clone(); + // `oss` model provider. let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; let cli_kv_overrides = match overrides_cli.parse_overrides() { @@ -147,7 +185,7 @@ pub async fn run_main( } }; - // we load config.toml here to determine project state. + // Load config.toml here to determine project state. #[allow(clippy::print_stderr)] let codex_home = match find_codex_home() { Ok(codex_home) => codex_home.to_path_buf(), @@ -245,9 +283,11 @@ pub async fn run_main( std::process::exit(1); } + // Phase 2: initialize logging and telemetry. let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; + // Open (or create) your log file, appending to it. let mut log_file_opts = OpenOptions::new(); log_file_opts.create(true).append(true); @@ -267,7 +307,7 @@ pub async fn run_main( // Wrap file in non‑blocking writer. let (non_blocking, _guard) = non_blocking(log_file); - // use RUST_LOG env var, default to info for codex crates. + // Use RUST_LOG env var; default to info for codex crates. let env_filter = || { EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") @@ -335,6 +375,7 @@ pub async fn run_main( .with(otel_tracing_layer) .try_init(); + // Phase 3: run the ratatui event loop and surface exit info. run_ratatui_app( cli, config, @@ -347,6 +388,17 @@ pub async fn run_main( .map_err(|err| std::io::Error::other(err.to_string())) } +/// Run the ratatui event loop and return the final exit summary. +/// +/// This performs one-time initialization (tooltips, panic hooks, session log), +/// runs onboarding when needed, resolves session selection (resume/fork/new), +/// and then hands control to [`App::run`]. Terminal teardown and session-log +/// finalization happen even on early exits. +/// +/// Phases: +/// 1. Initialize terminal helpers, tooltips, and panic reporting. +/// 2. Run update prompts and onboarding flows, reloading config if needed. +/// 3. Select a session and drive the main [`App`] event loop. async fn run_ratatui_app( cli: Cli, initial_config: Config, @@ -357,6 +409,7 @@ async fn run_ratatui_app( ) -> color_eyre::Result { color_eyre::install()?; + // Phase 1: initialize helpers and terminal state. tooltips::announcement::prewarm(); // Forward panic reports through tracing so they appear in the UI status @@ -394,6 +447,7 @@ async fn run_ratatui_app( } } + // Phase 2: run onboarding and reload config when user decisions change it. // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&initial_config); @@ -430,7 +484,9 @@ async fn run_ratatui_app( exit_reason: ExitReason::UserRequested, }); } - // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly + + // If the user acknowledged Windows or explicitly trusted the directory, + // reload the config accordingly. if onboarding_result .directory_trust_decision .map(|d| d == TrustDirectorySelection::Trust) @@ -444,6 +500,7 @@ async fn run_ratatui_app( initial_config }; + // Phase 3: select the session to load and launch the app loop. let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { Ok(notice) => notice, Err(err) => { @@ -594,12 +651,18 @@ async fn run_ratatui_app( .await; restore(); + // Mark the end of the recorded session. session_log::log_session_end(); - // ignore error when collecting usage – report underlying error instead + + // Ignore errors when collecting usage; report underlying error instead. app_result } +/// Restore the terminal to its pre-TUI state. +/// +/// This is called on exit paths where writing to stderr is safe because the +/// TUI has already been torn down. #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -615,10 +678,12 @@ fn restore() { /// Determine whether to use the terminal's alternate screen buffer. /// /// The alternate screen buffer provides a cleaner fullscreen experience without polluting -/// the terminal's scrollback history. However, it conflicts with terminal multiplexers like -/// Zellij that strictly follow the xterm spec, which disallows scrollback in alternate screen +/// the terminal's scrollback history, but it conflicts with multiplexers that disable +/// scrollback in alternate screen mode. +/// +/// Zellij strictly follows the xterm spec, which disallows scrollback in alternate screen /// buffers. Zellij intentionally disables scrollback in alternate screen mode (see -/// https://github.com/zellij-org/zellij/pull/1032) and offers no configuration option to +/// ) and offers no configuration option to /// change this behavior. /// /// This function implements a pragmatic workaround: @@ -643,12 +708,16 @@ fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScree } } +/// Summary of auth setup used to determine onboarding prompts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginStatus { + /// Auth is configured for a provider that requires login. AuthMode(AuthMode), + /// No valid credentials are available. NotAuthenticated, } +/// Determine the current login state for the configured provider. fn get_login_status(config: &Config) -> LoginStatus { if config.model_provider.requires_openai_auth { // Reading the OpenAI API key is an async operation because it may need @@ -667,6 +736,7 @@ fn get_login_status(config: &Config) -> LoginStatus { } } +/// Reload configuration after onboarding and exit the process on failure. async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, @@ -681,9 +751,10 @@ async fn load_config_or_exit( } } -/// Determine if user has configured a sandbox / approval policy, -/// or if the current cwd project is already trusted. If not, we need to -/// show the trust screen. +/// Determine whether the trust screen should be shown for the current project. +/// +/// The screen is skipped if the user has already configured approvals/sandbox +/// policy or if a trust decision has been recorded for the current project. fn should_show_trust_screen(config: &Config) -> bool { if cfg!(target_os = "windows") && get_platform_sandbox().is_none() { // If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access; skip the trust prompt entirely. @@ -693,10 +764,12 @@ fn should_show_trust_screen(config: &Config) -> bool { // Respect explicit approval/sandbox overrides made by the user. return false; } + // otherwise, show only if no trust decision has been made config.active_project.trust_level.is_none() } +/// Decide whether onboarding should run given login and trust state. fn should_show_onboarding( login_status: LoginStatus, config: &Config, @@ -709,6 +782,7 @@ fn should_show_onboarding( should_show_login_screen(login_status, config) } +/// Determine whether the login prompt should be shown for the current provider. fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { // Only show the login screen for providers that actually require OpenAI auth // (OpenAI or equivalents). For OSS/other providers, skip login entirely. @@ -727,6 +801,7 @@ mod tests { use serial_test::serial; use tempfile::TempDir; + /// Build a minimal config rooted at a temporary Codex home for tests. async fn build_config(temp_dir: &TempDir) -> std::io::Result { ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) @@ -734,6 +809,7 @@ mod tests { .await } + /// Ensures Windows skips the trust prompt when the sandbox is disabled. #[tokio::test] #[serial] async fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> { @@ -757,6 +833,8 @@ mod tests { } Ok(()) } + + /// Ensures Windows keeps the trust prompt when sandboxing is available. #[tokio::test] #[serial] async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { @@ -780,6 +858,8 @@ mod tests { } Ok(()) } + + /// Ensures explicit untrusted projects bypass the trust prompt. #[tokio::test] async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { use codex_protocol::config_types::TrustLevel; diff --git a/codex-rs/tui/src/live_wrap.rs b/codex-rs/tui/src/live_wrap.rs index e78710dc6c..25b750426a 100644 --- a/codex-rs/tui/src/live_wrap.rs +++ b/codex-rs/tui/src/live_wrap.rs @@ -1,15 +1,40 @@ +//! Wraps streaming text into visual rows based on display cell width. +//! +//! This module provides a lightweight, allocation-friendly wrapper for +//! incrementally breaking a stream into rows with explicit line-break +//! semantics. It is intentionally plain-text only; styling and ANSI handling +//! are layered in other render modules. Width is computed with +//! [`unicode_width`], so wide glyphs and emoji consume multiple columns. +//! +//! [`RowBuilder`] is designed for streaming input: callers can feed arbitrarily +//! fragmented chunks via [`RowBuilder::push_fragment`], and the resulting rows +//! stay stable regardless of chunk boundaries. The builder preserves logical +//! line breaks separately from hard wraps so downstream renderers can +//! distinguish explicit newlines from width-based wrapping. + use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; -/// A single visual row produced by RowBuilder. +/// A single visual row produced by [`RowBuilder`]. +/// +/// Rows are plain text plus a flag that records whether a newline ended the +/// row. Hard wraps are represented with `explicit_break` set to `false`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Row { + /// Text content for this row, stored without any styling metadata. + /// + /// The string excludes the trailing newline when `explicit_break` is true. pub text: String, + /// True if this row ends with an explicit line break (as opposed to a hard wrap). + /// + /// When true, downstream renderers should treat the row as a logical line + /// boundary rather than a purely visual wrap. pub explicit_break: bool, } impl Row { + /// Returns the display width of the row text in terminal cells. pub fn width(&self) -> usize { self.text.width() } @@ -17,16 +42,30 @@ impl Row { /// Incrementally wraps input text into visual rows of at most `width` cells. /// -/// Step 1: plain-text only. ANSI-carry and styled spans will be added later. +/// The builder maintains a buffer for the current logical line and a list of +/// fully wrapped rows. Callers can push fragments as they arrive, then query or +/// drain completed rows without losing partial-line state. This step is +/// plain-text only; ANSI-carry and styled spans are layered elsewhere. pub struct RowBuilder { + /// Target display width in terminal cells; clamped to at least 1. + /// + /// The width uses Unicode column counts rather than byte length. target_width: usize, - /// Buffer for the current logical line (until a '\n' is seen). + + /// Buffer for the current logical line (until a `\n` is seen). + /// + /// This buffer can be appended to incrementally between flushes. current_line: String, - /// Output rows built so far for the current logical line and previous ones. + + /// Output rows built so far for completed logical lines. + /// + /// The current logical line remains in `current_line` until wrapped or + /// flushed with an explicit break. rows: Vec, } impl RowBuilder { + /// Creates a new row builder with the requested target width. pub fn new(target_width: usize) -> Self { Self { target_width: target_width.max(1), @@ -35,12 +74,18 @@ impl RowBuilder { } } + /// Returns the target width in display cells. pub fn width(&self) -> usize { self.target_width } + /// Updates the target width and rewraps buffered content. + /// + /// All buffered rows and the current line are reflowed to match the new + /// width while preserving explicit break boundaries. pub fn set_width(&mut self, width: usize) { self.target_width = width.max(1); + // Rewrap everything we have (simple approach for Step 1). let mut all = String::new(); for row in self.rows.drain(..) { @@ -54,7 +99,10 @@ impl RowBuilder { self.push_fragment(&all); } - /// Push an input fragment. May contain newlines. + /// Pushes an input fragment, which may contain newlines. + /// + /// The fragment is split on `\n`, each completed logical line is flushed, + /// and any trailing partial line is wrapped against the current width. pub fn push_fragment(&mut self, fragment: &str) { if fragment.is_empty() { return; @@ -76,22 +124,24 @@ impl RowBuilder { } } - /// Mark the end of the current logical line (equivalent to pushing a '\n'). + /// Marks the end of the current logical line (equivalent to pushing `\n`). pub fn end_line(&mut self) { self.flush_current_line(true); } - /// Drain and return all produced rows. + /// Drains and returns all produced rows, leaving no committed rows behind. pub fn drain_rows(&mut self) -> Vec { std::mem::take(&mut self.rows) } - /// Return a snapshot of produced rows (non-draining). + /// Returns a snapshot of produced rows without draining them. pub fn rows(&self) -> &[Row] { &self.rows } - /// Rows suitable for display, including the current partial line if any. + /// Returns rows suitable for display, including the current partial line if any. + /// + /// The returned rows are clones; the builder state is unchanged. pub fn display_rows(&self) -> Vec { let mut out = self.rows.clone(); if !self.current_line.is_empty() { @@ -103,8 +153,11 @@ impl RowBuilder { out } - /// Drain the oldest rows that exceed `max_keep` display rows (including the - /// current partial line, if any). Returns the drained rows in order. + /// Drains the oldest rows that exceed `max_keep` display rows. + /// + /// The count includes the current partial line (if any) so callers can keep + /// the newest `max_keep` rows visible while committing older ones in order. + /// Drained rows are returned oldest-first. pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec { let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 }; if display_count <= max_keep { @@ -119,9 +172,11 @@ impl RowBuilder { drained } + /// Flushes the current logical line into rows, optionally marking a newline. fn flush_current_line(&mut self, explicit_break: bool) { // Wrap any remaining content in the current line and then finalize with explicit_break. self.wrap_current_line(); + // If the current line ended exactly on a width boundary and is non-empty, represent // the explicit break as an empty explicit row so that fragmentation invariance holds. if explicit_break { @@ -141,10 +196,12 @@ impl RowBuilder { }); } } + // Reset current line buffer for next logical line. self.current_line.clear(); } + /// Wraps buffered content into rows until the remainder fits the width. fn wrap_current_line(&mut self) { // While the current_line exceeds width, cut a prefix. loop { @@ -182,8 +239,14 @@ impl RowBuilder { } } -/// Take a prefix of `text` whose visible width is at most `max_cols`. -/// Returns (prefix, suffix, prefix_width). +/// Takes a prefix of `text` whose visible width is at most `max_cols`. +/// +/// Returns the prefix, the remaining suffix, and the prefix width in display +/// cells. Width is computed per Unicode scalar, so combining sequences may be +/// split if they exceed the limit. +/// +/// The returned prefix width may be less than `max_cols` if the next scalar +/// would exceed the available display cells. pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize) { if max_cols == 0 || text.is_empty() { return (String::new(), text, 0); @@ -237,6 +300,7 @@ mod tests { let mut rb = RowBuilder::new(6); rb.push_fragment("😀😀 你好"); let rows = rb.rows().to_vec(); + // At width 6, we expect the first row to fit exactly two emojis and a space // (2 + 2 + 1 = 5) plus one more column for the first CJK char (2 would overflow), // so only the two emojis and the space fit; the rest remains buffered. @@ -273,6 +337,7 @@ mod tests { let rows = rb.display_rows(); assert!(rows.iter().any(|r| r.explicit_break)); assert_eq!(rows[0].text, "hello"); + // Second row should begin with 'world' assert!(rows.iter().any(|r| r.text.starts_with("world"))); } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 50ea95f170..15a00816b6 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,18 +1,28 @@ +//! CLI entry point for the Codex TUI binary. +//! +//! This wrapper merges top-level config overrides with the TUI CLI arguments, +//! dispatches into the async runtime entry point, and prints token usage +//! summaries when available. + use clap::Parser; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; +/// Top-level CLI arguments that wrap the TUI CLI with shared overrides. #[derive(Parser, Debug)] struct TopCli { + /// Config override arguments collected at the top level. #[clap(flatten)] config_overrides: CliConfigOverrides, + /// The nested TUI CLI arguments. #[clap(flatten)] inner: Cli, } +/// Parse CLI args, run the async TUI entry point, and emit token usage. fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { let top_cli = TopCli::parse(); diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 2ea307066b..f0ddcc84eb 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -1,4 +1,15 @@ +//! Markdown rendering glue for the TUI text pipeline. +//! +//! This module adapts the markdown renderer into a simple `append_markdown` +//! helper that pushes rendered lines into a caller-provided buffer. It owns no +//! state and does not cache results; callers control when and how often the +//! source is re-rendered. use ratatui::text::Line; + +/// Renders markdown into owned lines and appends them to the caller buffer. +/// +/// The caller supplies the optional width hint and the destination vector so +/// the same buffer can be reused across incremental renders. pub(crate) fn append_markdown( markdown_source: &str, width: Option, @@ -14,6 +25,7 @@ mod tests { use pretty_assertions::assert_eq; use ratatui::text::Line; + /// Flattens rendered lines into plain strings for snapshot-style checks. fn lines_to_strings(lines: &[Line<'static>]) -> Vec { lines .iter() diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 4ba7b61316..36d244226c 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,3 +1,12 @@ +//! Markdown rendering for the TUI pipeline. +//! +//! This module parses markdown events, maps them to `ratatui` text lines, and +//! applies styling, indentation, and optional wrapping. The renderer is +//! stateful only for the duration of a single render call; callers pass the +//! source text and receive a `Text` value with owned lines. +//! +//! It focuses on predictable terminal output rather than perfect markdown +//! fidelity, favoring stable line boundaries for streaming and wrapping. use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -14,20 +23,35 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; +/// Style palette for markdown tokens rendered into `ratatui` lines. struct MarkdownStyles { + /// Style applied to level-one headings. h1: Style, + /// Style applied to level-two headings. h2: Style, + /// Style applied to level-three headings. h3: Style, + /// Style applied to level-four headings. h4: Style, + /// Style applied to level-five headings. h5: Style, + /// Style applied to level-six headings. h6: Style, + /// Style applied to inline and fenced code spans. code: Style, + /// Style applied to emphasized spans. emphasis: Style, + /// Style applied to strong spans. strong: Style, + /// Style applied to strikethrough spans. strikethrough: Style, + /// Style applied to ordered list markers. ordered_list_marker: Style, + /// Style applied to unordered list markers. unordered_list_marker: Style, + /// Style applied to rendered link URLs. link: Style, + /// Style applied to blockquote lines. blockquote: Style, } @@ -55,13 +79,18 @@ impl Default for MarkdownStyles { } #[derive(Clone, Debug)] +/// Tracks indentation and list markers for nested structures. struct IndentContext { + /// Prefix spans applied to each line within this indentation level. prefix: Vec>, + /// Optional list marker spans rendered on the first line of an item. marker: Option>>, + /// Whether this indentation level belongs to a list item. is_list: bool, } impl IndentContext { + /// Creates a new indentation context. fn new(prefix: Vec>, marker: Option>>, is_list: bool) -> Self { Self { prefix, @@ -75,6 +104,7 @@ pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, None) } +/// Renders markdown into `ratatui` text with an optional wrapping width. pub(crate) fn render_markdown_text_with_width(input: &str, width: Option) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); @@ -88,22 +118,39 @@ struct Writer<'a, I> where I: Iterator>, { + /// Markdown event iterator produced by the parser. iter: I, + /// Output buffer of rendered lines. text: Text<'static>, + /// Style palette for markdown elements. styles: MarkdownStyles, + /// Stack of inline styles currently active. inline_styles: Vec