-
Notifications
You must be signed in to change notification settings - Fork 11k
Expand file tree
/
Copy pathchatwidget.rs
More file actions
11069 lines (10343 loc) · 435 KB
/
chatwidget.rs
File metadata and controls
11069 lines (10343 loc) · 435 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! The main Codex TUI chat surface.
//!
//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering
//! for both the main viewport and overlay UIs.
//!
//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active
//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a
//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a
//! cached, render-only live tail derived from the current active cell so in-flight tool calls are
//! visible immediately.
//!
//! 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
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
//! every draw.
//!
//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt
//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn
//! 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`.
//!
//! For preamble-capable models, assistant output may include commentary before
//! the final answer. During streaming we hide the status row to avoid duplicate
//! progress indicators; once commentary completes and stream queues drain, we
//! re-show it so users still see turn-in-progress state between output bursts.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use url::Url;
use self::realtime::PendingSteerCompareKey;
use crate::app_command::AppCommand;
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_server_approval_conversions::network_approval_context_to_core;
use crate::app_server_session::ThreadSessionState;
#[cfg(not(target_os = "linux"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::model_catalog::ModelCatalog;
use crate::multi_agents;
use crate::status::RateLimitWindowDisplay;
use crate::status::StatusAccountDisplay;
use crate::status::StatusHistoryHandle;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
use crate::status::rate_limit_snapshot_display_for_limit;
use crate::terminal_title::SetTerminalTitleResult;
use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_app_server_protocol::TurnStatus;
use codex_chatgpt::connectors;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
use codex_config::types::WindowsSandboxModeToml;
use codex_core::config::Config;
use codex_core::config::Constrained;
use codex_core::config::ConstraintResult;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::find_thread_name_by_id;
use codex_core::plugins::PluginsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::skills::model::SkillMetadata;
#[cfg(target_os = "windows")]
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_features::FEATURES;
use codex_features::Feature;
#[cfg(test)]
use codex_git_utils::CommitLogEntry;
use codex_git_utils::current_branch_name;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::local_git_branches;
use codex_git_utils::recent_commits;
use codex_otel::RuntimeMetricsSummary;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageDeltaEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentReasoningDeltaEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentReasoningEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentReasoningRawContentEvent;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
#[cfg(test)]
use codex_protocol::protocol::BackgroundEventEvent;
#[cfg(test)]
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CollabAgentRef;
#[cfg(test)]
use codex_protocol::protocol::CollabAgentSpawnBeginEvent;
use codex_protocol::protocol::CollabAgentStatusEntry;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::DeprecationNoticeEvent;
#[cfg(test)]
use codex_protocol::protocol::ErrorEvent;
#[cfg(test)]
use codex_protocol::protocol::Event;
#[cfg(test)]
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecCommandSource;
#[cfg(test)]
use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::GuardianAssessmentAction;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::ImageGenerationBeginEvent;
use codex_protocol::protocol::ImageGenerationEndEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpListToolsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
#[cfg(test)]
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
#[cfg(test)]
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::TerminalInteractionEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnAbortReason;
#[cfg(test)]
use codex_protocol::protocol::TurnCompleteEvent;
#[cfg(test)]
use codex_protocol::protocol::TurnDiffEvent;
#[cfg(test)]
use codex_protocol::protocol::UndoCompletedEvent;
#[cfg(test)]
use codex_protocol::protocol::UndoStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::ViewImageToolCallEvent;
#[cfg(test)]
use codex_protocol::protocol::WarningEvent;
use codex_protocol::protocol::WebSearchBeginEvent;
use codex_protocol::protocol::WebSearchEndEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_sleep_inhibitor::SleepInhibitor;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use rand::Rng;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tracing::debug;
use tracing::warn;
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session.";
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
const TUI_STUB_MESSAGE: &str = "Not available in TUI yet.";
/// Choose the keybinding used to edit the most-recently queued message.
///
/// Apple Terminal, Warp, and VSCode integrated terminals intercept or silently
/// swallow Alt+Up, and tmux does not reliably pass that chord through. We fall
/// back to Shift+Left for those environments while keeping the more discoverable
/// Alt+Up everywhere else.
///
/// The match is exhaustive so that adding a new `TerminalName` variant forces
/// an explicit decision about which binding that terminal should use.
fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyBinding {
if matches!(
terminal_info.multiplexer.as_ref(),
Some(Multiplexer::Tmux { .. })
) {
return key_hint::shift(KeyCode::Left);
}
match terminal_info.name {
TerminalName::AppleTerminal | TerminalName::WarpTerminal | TerminalName::VsCode => {
key_hint::shift(KeyCode::Left)
}
TerminalName::Ghostty
| TerminalName::Iterm2
| TerminalName::WezTerm
| TerminalName::Kitty
| TerminalName::Alacritty
| TerminalName::Konsole
| TerminalName::GnomeTerminal
| TerminalName::Vte
| TerminalName::WindowsTerminal
| TerminalName::Dumb
| TerminalName::Unknown => key_hint::alt(KeyCode::Up),
}
}
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::CollaborationModeIndicator;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
use crate::bottom_pane::ExperimentalFeatureItem;
use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::clipboard_text;
use crate::collaboration_modes;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
use crate::exec_cell::new_active_exec_command;
use crate::exec_command::split_command_string;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::get_git_diff::get_git_diff;
use crate::history_cell;
#[cfg(test)]
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
#[cfg(test)]
use crate::markdown::append_markdown;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
mod interrupts;
use self::interrupts::InterruptManager;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
mod plugins;
use self::plugins::PluginsCacheState;
mod realtime;
use self::realtime::RealtimeConversationUiState;
use self::realtime::RenderedUserMessageEvent;
mod status_surfaces;
use self::status_surfaces::CachedProjectRootName;
use self::status_surfaces::TerminalTitleStatusKind;
use crate::streaming::chunking::AdaptiveChunkingPolicy;
use crate::streaming::commit_tick::CommitTickScope;
use crate::streaming::commit_tick::run_commit_tick;
use crate::streaming::controller::PlanStreamController;
use crate::streaming::controller::StreamController;
use chrono::Local;
use codex_file_search::FileMatch;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_approval_presets::ApprovalPreset;
use codex_utils_approval_presets::builtin_approval_presets;
use strum::IntoEnumIterator;
use unicode_segmentation::UnicodeSegmentation;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls";
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
const FAST_STATUS_MODEL: &str = "gpt-5.4";
const DEFAULT_STATUS_LINE_ITEMS: [&str; 3] =
["model-with-reasoning", "context-remaining", "current-dir"];
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
parsed_cmd: Vec<ParsedCommand>,
source: ExecCommandSource,
}
struct UnifiedExecProcessSummary {
key: String,
call_id: String,
command_display: String,
recent_chunks: Vec<String>,
}
struct UnifiedExecWaitState {
command_display: String,
}
impl UnifiedExecWaitState {
fn new(command_display: String) -> Self {
Self { command_display }
}
fn is_duplicate(&self, command_display: &str) -> bool {
self.command_display == command_display
}
}
#[derive(Clone, Debug)]
struct UnifiedExecWaitStreak {
process_id: String,
command_display: Option<String>,
}
impl UnifiedExecWaitStreak {
fn new(process_id: String, command_display: Option<String>) -> Self {
Self {
process_id,
command_display: command_display.filter(|display| !display.is_empty()),
}
}
fn update_command_display(&mut self, command_display: Option<String>) {
if self.command_display.is_some() {
return;
}
self.command_display = command_display.filter(|display| !display.is_empty());
}
}
fn is_unified_exec_source(source: ExecCommandSource) -> bool {
matches!(
source,
ExecCommandSource::UnifiedExecStartup | ExecCommandSource::UnifiedExecInteraction
)
}
fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
!parsed_cmd.is_empty()
&& parsed_cmd
.iter()
.all(|parsed| !matches!(parsed, ParsedCommand::Unknown { .. }))
}
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini";
const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0;
#[derive(Default)]
struct RateLimitWarningState {
secondary_index: usize,
primary_index: usize,
}
impl RateLimitWarningState {
fn take_warnings(
&mut self,
secondary_used_percent: Option<f64>,
secondary_window_minutes: Option<i64>,
primary_used_percent: Option<f64>,
primary_window_minutes: Option<i64>,
) -> Vec<String> {
let reached_secondary_cap =
matches!(secondary_used_percent, Some(percent) if percent == 100.0);
let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0);
if reached_secondary_cap || reached_primary_cap {
return Vec::new();
}
let mut warnings = Vec::new();
if let Some(secondary_used_percent) = secondary_used_percent {
let mut highest_secondary: Option<f64> = None;
while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
&& secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]
{
highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]);
self.secondary_index += 1;
}
if let Some(threshold) = highest_secondary {
let limit_label = secondary_window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
let remaining_percent = 100.0 - threshold;
warnings.push(format!(
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
));
}
}
if let Some(primary_used_percent) = primary_used_percent {
let mut highest_primary: Option<f64> = None;
while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len()
&& primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]
{
highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]);
self.primary_index += 1;
}
if let Some(threshold) = highest_primary {
let limit_label = primary_window_minutes
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
let remaining_percent = 100.0 - threshold;
warnings.push(format!(
"Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown."
));
}
}
warnings
}
}
pub(crate) fn get_limits_duration(windows_minutes: i64) -> String {
const MINUTES_PER_HOUR: i64 = 60;
const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR;
const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY;
const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY;
const ROUNDING_BIAS_MINUTES: i64 = 3;
let windows_minutes = windows_minutes.max(0);
if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) {
let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES);
let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR);
format!("{hours}h")
} else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) {
"weekly".to_string()
} else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) {
"monthly".to_string()
} else {
"annual".to_string()
}
}
/// Common initialization parameters shared by all `ChatWidget` constructors.
pub(crate) struct ChatWidgetInit {
pub(crate) config: Config,
pub(crate) frame_requester: FrameRequester,
pub(crate) app_event_tx: AppEventSender,
pub(crate) initial_user_message: Option<UserMessage>,
pub(crate) enhanced_keys_supported: bool,
pub(crate) has_chatgpt_account: bool,
pub(crate) model_catalog: Arc<ModelCatalog>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) initial_plan_type: Option<PlanType>,
pub(crate) model: Option<String>,
pub(crate) startup_tooltip_override: Option<String>,
// Shared latch so we only warn once about invalid status-line item IDs.
pub(crate) status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared latch so we only warn once about invalid terminal-title item IDs.
pub(crate) terminal_title_invalid_items_warned: Arc<AtomicBool>,
pub(crate) session_telemetry: SessionTelemetry,
}
#[derive(Default)]
enum RateLimitSwitchPromptState {
#[default]
Idle,
Pending,
Shown,
}
#[derive(Debug, Clone, Default)]
enum ConnectorsCacheState {
#[default]
Uninitialized,
Loading,
Ready(ConnectorsSnapshot),
Failed(String),
}
#[derive(Debug, Clone, Default)]
struct PluginListFetchState {
cache_cwd: Option<PathBuf>,
in_flight_cwd: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct PluginInstallAuthFlowState {
plugin_display_name: String,
next_app_index: usize,
}
#[derive(Debug)]
enum RateLimitErrorKind {
ServerOverloaded,
UsageLimit,
Generic,
}
#[cfg(test)]
fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
CoreCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
CoreCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
CoreCodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: Some(429),
} => Some(RateLimitErrorKind::Generic),
_ => None,
}
}
fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
AppServerCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
AppServerCodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: Some(429),
} => Some(RateLimitErrorKind::Generic),
_ => None,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ExternalEditorState {
#[default]
Closed,
Requested,
Active,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StatusIndicatorState {
header: String,
details: Option<String>,
details_max_lines: usize,
}
impl StatusIndicatorState {
fn working() -> Self {
Self {
header: String::from("Working"),
details: None,
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
}
}
fn is_guardian_review(&self) -> bool {
self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ")
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct PendingGuardianReviewStatus {
entries: Vec<PendingGuardianReviewStatusEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PendingGuardianReviewStatusEntry {
id: String,
detail: String,
}
impl PendingGuardianReviewStatus {
fn start_or_update(&mut self, id: String, detail: String) {
if let Some(existing) = self.entries.iter_mut().find(|entry| entry.id == id) {
existing.detail = detail;
} else {
self.entries
.push(PendingGuardianReviewStatusEntry { id, detail });
}
}
fn finish(&mut self, id: &str) -> bool {
let original_len = self.entries.len();
self.entries.retain(|entry| entry.id != id);
self.entries.len() != original_len
}
fn is_empty(&self) -> bool {
self.entries.is_empty()
}
// Guardian review status is derived from the full set of currently pending
// review entries. The generic status cache on `ChatWidget` stores whichever
// footer is currently rendered; this helper computes the guardian-specific
// footer snapshot that should replace it while reviews remain in flight.
fn status_indicator_state(&self) -> Option<StatusIndicatorState> {
let details = if self.entries.len() == 1 {
self.entries.first().map(|entry| entry.detail.clone())
} else if self.entries.is_empty() {
None
} else {
let mut lines = self
.entries
.iter()
.take(3)
.map(|entry| format!("• {}", entry.detail))
.collect::<Vec<_>>();
let remaining = self.entries.len().saturating_sub(3);
if remaining > 0 {
lines.push(format!("+{remaining} more"));
}
Some(lines.join("\n"))
};
let details = details?;
let header = if self.entries.len() == 1 {
String::from("Reviewing approval request")
} else {
format!("Reviewing {} approval requests", self.entries.len())
};
let details_max_lines = if self.entries.len() == 1 { 1 } else { 4 };
Some(StatusIndicatorState {
header,
details: Some(details),
details_max_lines,
})
}
}
/// Maintains the per-session UI state and interaction state machines for the chat screen.
///
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
/// intent (`Op` submissions and `AppEvent` requests).
///
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
/// and by sending requests back to codex-core.
///
/// 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.
pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_target: CodexOpTarget,
bottom_pane: BottomPane,
active_cell: Option<Box<dyn HistoryCell>>,
/// Monotonic-ish counter used to invalidate transcript overlay caching.
///
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
/// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer
/// identity alone is not a good cache key.
///
/// Callers bump this whenever the active cell's transcript output could change without
/// 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,
config: Config,
/// The unmasked collaboration mode settings (always Default mode).
///
/// Masks are applied on top of this base mode to derive the effective mode.
current_collaboration_mode: CollaborationMode,
/// The currently active collaboration mask, if any.
active_collaboration_mask: Option<CollaborationModeMask>,
has_chatgpt_account: bool,
model_catalog: Arc<ModelCatalog>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
initial_user_message: Option<UserMessage>,
status_account_display: Option<StatusAccountDisplay>,
token_info: Option<TokenUsageInfo>,
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
plan_type: Option<PlanType>,
rate_limit_warnings: RateLimitWarningState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
adaptive_chunking: AdaptiveChunkingPolicy,
// Stream lifecycle controller
stream_controller: Option<StreamController>,
// Stream lifecycle controller for proposed plan output.
plan_stream_controller: Option<PlanStreamController>,
// Latest completed user-visible Codex output that `/copy` should place on the clipboard.
last_copyable_output: Option<String>,
// Latest agent message observed during the active turn. App-server turn completion
// notifications do not repeat this payload, so we promote it when the turn completes.
pending_turn_copyable_output: Option<String>,
running_commands: HashMap<String, RunningCommand>,
collab_agent_metadata: HashMap<ThreadId, CollabAgentMetadata>,
pending_collab_spawn_requests: HashMap<String, multi_agents::SpawnRequestSummary>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
skills_initial_state: Option<HashMap<PathBuf, bool>>,
last_unified_wait: Option<UnifiedExecWaitState>,
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
turn_sleep_inhibitor: SleepInhibitor,
task_complete_pending: bool,
unified_exec_processes: Vec<UnifiedExecProcessSummary>,
/// 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<HashMap<String, McpStartupStatus>>,
/// Expected MCP servers for the current startup round, seeded from enabled local config.
mcp_startup_expected_servers: Option<HashSet<String>>,
/// After startup settles, ignore stale updates until enough notifications confirm a new round.
mcp_startup_ignore_updates_until_next_start: bool,
/// A lag signal for the next round means terminal-only updates are enough to settle it.
mcp_startup_allow_terminal_only_next_round: bool,
/// Buffers post-settle MCP startup updates until they cover a full fresh round.
mcp_startup_pending_next_round: HashMap<String, McpStartupStatus>,
/// Tracks whether the buffered next round has seen any `Starting` update yet.
mcp_startup_pending_next_round_saw_starting: bool,
connectors_cache: ConnectorsCacheState,
connectors_partial_snapshot: Option<ConnectorsSnapshot>,
connectors_prefetch_in_flight: bool,
connectors_force_refetch_pending: bool,
plugins_cache: PluginsCacheState,
plugins_fetch_state: PluginListFetchState,
plugin_install_apps_needing_auth: Vec<AppSummary>,
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
// Queue of interruptive UI events deferred during an active write cycle
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
reasoning_buffer: String,
// Accumulates full reasoning content for transcript-only recording
full_reasoning_buffer: String,
// The currently rendered footer state. We keep the already-formatted
// details here so transient stream interruptions can restore the footer
// exactly as it was shown.
current_status: StatusIndicatorState,
// Guardian review keeps its own pending set so it can derive a single
// footer summary from one or more in-flight review events.
pending_guardian_review_status: PendingGuardianReviewStatus,
// Semantic status used for terminal-title status rendering.
terminal_title_status_kind: TerminalTitleStatusKind,
// Previous status header to restore after a transient stream retry.
retry_status_header: Option<String>,
// Set when commentary output completes; once stream queues go idle we restore the status row.
pending_status_indicator_restore: bool,
suppress_queue_autosend: bool,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
// One-shot tooltip override for the primary startup session.
startup_tooltip_override: Option<String>,
// 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,
// During snapshot restore, defer startup prompt submission until replayed
// history has been rendered so resumed/forked prompts keep chronological
// order.
suppress_initial_user_message_submit: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
// User messages that tried to steer a non-regular turn and must be retried first.
rejected_steers_queue: VecDeque<UserMessage>,
// Steers already submitted to core but not yet committed into history.
//
// The bottom pane shows these above queued drafts until core records the
// corresponding user message item.
pending_steers: VecDeque<PendingSteer>,
// When set, the next interrupt should resubmit all pending steers as one
// fresh user turn instead of restoring them into the composer.
submit_pending_steers_after_interrupt: bool,
/// Terminal-appropriate keybinding for popping the most-recently queued
/// message back into the composer. Determined once at construction time via
/// [`queued_message_edit_binding_for_terminal`] and propagated to
/// `BottomPane` so the hint text matches the actual shortcut.
queued_message_edit_binding: KeyBinding,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// 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<Instant>,
/// 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<KeyBinding>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
// Snapshot of token usage to restore after review mode exits.
pre_review_token_info: Option<Option<TokenUsageInfo>>,
// 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.
had_work_activity: bool,
// Whether the current turn emitted a plan update.
saw_plan_update_this_turn: bool,
// Whether the current turn emitted a proposed plan item that has not been superseded by a
// later steer. This is cleared when the user submits a steer so the plan popup only appears
// if a newer proposed plan arrives afterward.
saw_plan_item_this_turn: bool,
// Latest `update_plan` checklist task counts for terminal-title rendering.
last_plan_progress: Option<(usize, usize)>,
// Incremental buffer for streamed plan content.
plan_delta_buffer: String,
// True while a plan item is streaming.
plan_item_active: bool,
// Status-indicator elapsed seconds captured at the last emitted final-message separator.
//
// This lets the separator show per-chunk work time (since the previous separator) rather than
// the total task-running time reported by the status indicator.
last_separator_elapsed_secs: Option<u64>,
// Runtime metrics accumulated across delta snapshots for the active turn.
turn_runtime_metrics: RuntimeMetricsSummary,
last_rendered_width: std::cell::Cell<Option<usize>>,
// Feedback sink for /feedback
feedback: codex_feedback::CodexFeedback,
// Current session rollout path (if known)
current_rollout_path: Option<PathBuf>,
// Current working directory (if known)
current_cwd: Option<PathBuf>,
// Runtime network proxy bind addresses from SessionConfigured.
session_network_proxy: Option<codex_protocol::protocol::SessionNetworkProxyRuntime>,
// Shared latch so we only warn once about invalid status-line item IDs.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared latch so we only warn once about invalid terminal-title item IDs.
terminal_title_invalid_items_warned: Arc<AtomicBool>,
// Last terminal title emitted, to avoid writing duplicate OSC updates.
pub(crate) last_terminal_title: Option<String>,
// Original terminal-title config captured when the setup UI opens.
//
// The outer `Option` tracks whether a setup session is active (`Some`)
// or not (`None`). The inner `Option<Vec<String>>` mirrors the shape
// of `config.tui_terminal_title` (which is `None` when using defaults).
// On cancel or persist-failure the inner value is restored to config;
// on confirm the outer is set to `None` to end the session.
terminal_title_setup_original_items: Option<Option<Vec<String>>>,
// Baseline instant used to animate spinner-prefixed title statuses.
terminal_title_animation_origin: Instant,
// Cached project-root display name keyed by cwd for status/title rendering.
status_line_project_root_name_cache: Option<CachedProjectRootName>,
// Cached git branch name for the status line (None if unknown).
status_line_branch: Option<String>,
// CWD used to resolve the cached branch; change resets branch state.
status_line_branch_cwd: Option<PathBuf>,
// True while an async branch lookup is in flight.
status_line_branch_pending: bool,
// True once we've attempted a branch lookup for the current CWD.
status_line_branch_lookup_complete: bool,
external_editor_state: ExternalEditorState,
realtime_conversation: RealtimeConversationUiState,
last_rendered_user_message_event: Option<RenderedUserMessageEvent>,
last_non_retry_error: Option<(String, String)>,
}
/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to
/// rendered tool-call items.
///
/// Populated externally by `App` via `set_collab_agent_metadata` and consulted by the
/// notification-to-core-event conversion helpers. Defaults to empty so that missing metadata
/// degrades to the previous behavior of showing raw thread ids.
#[derive(Clone, Debug, Default)]
struct CollabAgentMetadata {
agent_nickname: Option<String>,
agent_role: Option<String>,
}
#[cfg_attr(not(test), allow(dead_code))]
enum CodexOpTarget {
Direct(UnboundedSender<Op>),
AppEvent,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
///
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
/// it cheaply decide when to recompute that tail as the active cell evolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ActiveCellTranscriptKey {
/// Cache-busting revision for in-place updates.
///
/// Many active cells are updated incrementally while streaming (for example when exec groups
/// add output or change status), and the transcript overlay caches its live tail, so this
/// 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
/// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any
/// underlying data change.
pub(crate) animation_tick: Option<u64>,
}