Use backend service-tier metadata in app-server and TUI

This commit is contained in:
Ahmed Ibrahim
2026-05-02 21:53:20 +03:00
parent c37f7434ba
commit c76e96986d
60 changed files with 1121 additions and 594 deletions
@@ -11208,10 +11208,10 @@
},
"Model": {
"properties": {
"additionalSpeedTiers": {
"serviceTiers": {
"default": [],
"items": {
"type": "string"
"$ref": "#/definitions/v2/ModelServiceTier"
},
"type": "array"
},
@@ -11306,6 +11306,25 @@
],
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"$ref": "#/definitions/v2/ServiceTier"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14143,10 +14162,7 @@
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.",
"type": "string"
},
"SessionMigration": {
@@ -18230,4 +18246,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -7862,10 +7862,10 @@
},
"Model": {
"properties": {
"additionalSpeedTiers": {
"serviceTiers": {
"default": [],
"items": {
"type": "string"
"$ref": "#/definitions/ModelServiceTier"
},
"type": "array"
},
@@ -7960,6 +7960,25 @@
],
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"$ref": "#/definitions/ServiceTier"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12029,10 +12048,7 @@
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.",
"type": "string"
},
"SessionMigration": {
@@ -16115,4 +16131,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -22,10 +22,10 @@
},
"Model": {
"properties": {
"additionalSpeedTiers": {
"serviceTiers": {
"default": [],
"items": {
"type": "string"
"$ref": "#/definitions/ModelServiceTier"
},
"type": "array"
},
@@ -120,6 +120,25 @@
],
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"$ref": "#/definitions/ServiceTier"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
@@ -175,6 +194,10 @@
"reasoningEffort"
],
"type": "object"
},
"ServiceTier": {
"description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.",
"type": "string"
}
},
"properties": {
@@ -197,4 +220,4 @@
],
"title": "ModelListResponse",
"type": "object"
}
}
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ServiceTier = "fast" | "flex";
export type ServiceTier = string;
+2 -1
View File
@@ -4,7 +4,8 @@
import type { InputModality } from "../InputModality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
import type { ModelServiceTier } from "./ModelServiceTier";
import type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
import type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, additionalSpeedTiers: Array<string>, isDefault: boolean, };
export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServiceTier } from "../ServiceTier";
export type ModelServiceTier = { id: ServiceTier, name: string, description: string, };
@@ -241,6 +241,7 @@ export type { Model } from "./Model";
export type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
export type { ModelListParams } from "./ModelListParams";
export type { ModelListResponse } from "./ModelListResponse";
export type { ModelServiceTier } from "./ModelServiceTier";
export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabilitiesReadParams";
export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse";
export type { ModelRerouteReason } from "./ModelRerouteReason";
@@ -49,6 +49,7 @@ use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux;
use codex_protocol::openai_models::ModelServiceTier as CoreModelServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
@@ -2508,6 +2509,35 @@ impl From<CoreModelAvailabilityNux> for ModelAvailabilityNux {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelServiceTier {
pub id: ServiceTier,
pub name: String,
pub description: String,
}
impl From<CoreModelServiceTier> for ModelServiceTier {
fn from(value: CoreModelServiceTier) -> Self {
Self {
id: value.id,
name: value.name,
description: value.description,
}
}
}
impl From<ModelServiceTier> for CoreModelServiceTier {
fn from(value: ModelServiceTier) -> Self {
Self {
id: value.id,
name: value.name,
description: value.description,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2527,7 +2557,7 @@ pub struct Model {
#[serde(default)]
pub supports_personality: bool,
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
pub service_tiers: Vec<ModelServiceTier>,
// Only one model should be marked as default.
pub is_default: bool,
}
+1 -1
View File
@@ -191,7 +191,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `serviceTiers` (`id`, `name`, `description`), optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
@@ -10352,7 +10352,7 @@ mod tests {
path: None,
model: None,
model_provider: None,
service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)),
service_tier: Some(Some(codex_protocol::config_types::ServiceTier::priority())),
cwd: None,
approval_policy: None,
approvals_reviewer: None,
@@ -10368,7 +10368,7 @@ mod tests {
let config_snapshot = ThreadConfigSnapshot {
model: "gpt-5".to_string(),
model_provider_id: "openai".to_string(),
service_tier: Some(codex_protocol::config_types::ServiceTier::Flex),
service_tier: Some(codex_protocol::config_types::ServiceTier::flex()),
approval_policy: codex_protocol::protocol::AskForApproval::OnRequest,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
+1 -1
View File
@@ -42,7 +42,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
default_reasoning_effort: preset.default_reasoning_effort,
input_modalities: preset.input_modalities,
supports_personality: preset.supports_personality,
additional_speed_tiers: preset.additional_speed_tiers,
service_tiers: preset.service_tiers.into_iter().map(Into::into).collect(),
is_default: preset.is_default,
}
}
@@ -28,7 +28,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
},
supported_in_api: preset.supported_in_api,
priority,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset.service_tiers.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -50,7 +50,12 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
// cache report `supports_personality = false`.
// todo(sayan): fix, maybe make roundtrip use ModelInfo only
supports_personality: false,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset
.service_tiers
.clone()
.into_iter()
.map(Into::into)
.collect(),
is_default: preset.is_default,
}
}
@@ -340,7 +340,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> {
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(ServiceTier::Flex)),
service_tier: Some(Some(ServiceTier::flex())),
..Default::default()
})
.await?;
@@ -352,7 +352,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(ServiceTier::Flex));
assert_eq!(service_tier, Some(ServiceTier::flex()));
Ok(())
}
@@ -74,7 +74,7 @@ async fn models_client_hits_models_endpoint() {
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+2 -5
View File
@@ -2107,10 +2107,7 @@
"type": "object"
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"description": "String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed.",
"type": "string"
},
"ShellEnvironmentPolicyInherit": {
@@ -4036,4 +4033,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+1 -5
View File
@@ -889,11 +889,7 @@ impl ModelClientSession {
store: provider.is_azure_responses_endpoint(),
stream: true,
include,
service_tier: match service_tier {
Some(ServiceTier::Fast) => Some("priority".to_string()),
Some(service_tier) => Some(service_tier.to_string()),
None => None,
},
service_tier: service_tier.map(|service_tier| service_tier.to_string()),
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
+1 -1
View File
@@ -155,7 +155,7 @@ fn serializes_flex_service_tier_when_set() {
stream: true,
include: vec![],
prompt_cache_key: None,
service_tier: Some(ServiceTier::Flex.to_string()),
service_tier: Some(ServiceTier::flex().to_string()),
text: None,
client_metadata: None,
};
+1 -1
View File
@@ -530,7 +530,7 @@ async fn drain_to_completed(
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_context.config.service_tier.clone(),
turn_metadata_header,
// Rollout tracing currently models remote compaction only; local compaction streams
// are left untraced until the reducer has a first-class local compaction lifecycle.
+3 -5
View File
@@ -2578,12 +2578,10 @@ impl Config {
None => config_profile.service_tier.or(cfg.service_tier),
};
let service_tier = match service_tier {
Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => {
Some(ServiceTier::Fast)
Some(service_tier) if service_tier.is_priority() && !features.enabled(Feature::FastMode) => {
None
}
Some(ServiceTier::Fast) => None,
Some(ServiceTier::Flex) => Some(ServiceTier::Flex),
None => None,
other => other,
};
let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| {
+2 -2
View File
@@ -586,7 +586,7 @@ impl Codex {
.auth_cached()
.and_then(|auth| auth.account_plan_type());
let service_tier = get_service_tier(
config.service_tier,
config.service_tier.clone(),
config.notices.fast_default_opt_out.unwrap_or(false),
account_plan_type,
config.features.enabled(Feature::FastMode),
@@ -789,7 +789,7 @@ fn get_service_tier(
account_plan_type
.is_some_and(is_enterprise_default_service_tier_plan)
.then_some(ServiceTier::Fast)
.then(ServiceTier::priority)
}
fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool {
+2 -2
View File
@@ -130,7 +130,7 @@ impl SessionConfiguration {
ThreadConfigSnapshot {
model: self.collaboration_mode.model().to_string(),
model_provider_id: self.original_config_do_not_use.model_provider_id.clone(),
service_tier: self.service_tier,
service_tier: self.service_tier.clone(),
approval_policy: self.approval_policy.value(),
approvals_reviewer: self.approvals_reviewer,
permission_profile: self.permission_profile(),
@@ -175,7 +175,7 @@ impl SessionConfiguration {
if let Some(summary) = updates.reasoning_summary {
next_configuration.model_reasoning_summary = Some(summary);
}
if let Some(service_tier) = updates.service_tier {
if let Some(service_tier) = updates.service_tier.clone() {
next_configuration.service_tier = service_tier;
}
if let Some(personality) = updates.personality {
+5 -5
View File
@@ -2790,7 +2790,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Enterprise),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::priority())
);
assert_eq!(
get_service_tier(
@@ -2799,7 +2799,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::EnterpriseCbpUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::priority())
);
assert_eq!(
get_service_tier(
@@ -2808,7 +2808,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Business),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::priority())
);
assert_eq!(
get_service_tier(
@@ -2817,7 +2817,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::Team),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::priority())
);
assert_eq!(
get_service_tier(
@@ -2826,7 +2826,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() {
Some(AccountPlanType::SelfServeBusinessUsageBased),
/*fast_mode_enabled*/ true,
),
Some(ServiceTier::Fast)
Some(ServiceTier::priority())
);
}
+2 -2
View File
@@ -689,7 +689,7 @@ async fn track_turn_resolved_config_analytics(
permission_profile_cwd: turn_context.cwd.to_path_buf(),
reasoning_effort: turn_context.reasoning_effort,
reasoning_summary: Some(turn_context.reasoning_summary),
service_tier: turn_context.config.service_tier,
service_tier: turn_context.config.service_tier.clone(),
approval_policy: turn_context.approval_policy.value(),
approvals_reviewer: turn_context.config.approvals_reviewer,
sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(),
@@ -1837,7 +1837,7 @@ async fn try_run_sampling_request(
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_context.config.service_tier.clone(),
turn_metadata_header,
&inference_trace,
)
+1 -1
View File
@@ -394,7 +394,7 @@ impl Session {
per_turn_config.model_reasoning_effort =
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
per_turn_config.service_tier = session_configuration.service_tier;
per_turn_config.service_tier = session_configuration.service_tier.clone();
per_turn_config.personality = session_configuration.personality;
per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer;
per_turn_config.permissions.permission_profile =
+3 -3
View File
@@ -269,7 +269,7 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm()
assert_eq!(warmup["generate"].as_bool(), Some(false));
assert_eq!(warmup.get("service_tier"), None);
test.submit_turn_with_service_tier("hello", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("hello", Some(ServiceTier::priority()))
.await?;
assert_eq!(server.handshakes().len(), 1);
@@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu
.features
.enable(Feature::ResponsesWebsocketsV2)
.expect("test config should allow feature update");
config.service_tier = Some(ServiceTier::Fast);
config.service_tier = Some(ServiceTier::priority());
});
let test = builder.build_with_websocket_server(&server).await?;
@@ -385,7 +385,7 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> {
assert_eq!(warmup["generate"].as_bool(), Some(false));
assert_eq!(warmup.get("service_tier"), None);
test.submit_turn_with_service_tier("first", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("first", Some(ServiceTier::priority()))
.await?;
test.submit_turn_with_service_tier("second", /*service_tier*/ None)
.await?;
+4 -4
View File
@@ -105,7 +105,7 @@ fn test_model_info(
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -297,7 +297,7 @@ async fn service_tier_change_is_applied_on_next_http_turn() -> Result<()> {
let test = test_codex().build(&server).await?;
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::Fast))
test.submit_turn_with_service_tier("fast turn", Some(ServiceTier::priority()))
.await?;
test.submit_turn_with_service_tier("standard turn", /*service_tier*/ None)
.await?;
@@ -323,7 +323,7 @@ async fn flex_service_tier_is_applied_to_http_turn() -> Result<()> {
let test = test_codex().build(&server).await?;
test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::Flex))
test.submit_turn_with_service_tier("flex turn", Some(ServiceTier::flex()))
.await?;
let request = resp_mock.single_request();
@@ -817,7 +817,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -340,7 +340,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+2 -2
View File
@@ -578,7 +578,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
@@ -686,7 +686,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: Some(ModelMessages {
+3 -3
View File
@@ -532,7 +532,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -788,7 +788,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: remote_base.to_string(),
model_messages: None,
@@ -1278,7 +1278,7 @@ fn test_remote_model_with_policy(
used_fallback_model_metadata: false,
supports_search_tool: false,
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1 -1
View File
@@ -1282,7 +1282,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -66,7 +66,7 @@ fn test_model_info(
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+1 -1
View File
@@ -1059,7 +1059,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
used_fallback_model_metadata: false,
supports_search_tool: false,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+4 -4
View File
@@ -253,14 +253,14 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu
model: None,
effort: None,
summary: None,
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(ServiceTier::priority())),
collaboration_mode: None,
personality: None,
})
.await?;
let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?;
assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast));
let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::priority())).await?;
assert_eq!(config_snapshot.service_tier, Some(ServiceTier::priority()));
let context = crate::runtime::MemoryStartupContext::new(
Arc::clone(&test.thread_manager),
@@ -277,7 +277,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu
ReasoningEffort::Low,
)
.await;
assert_eq!(request_context.service_tier, Some(ServiceTier::Fast));
assert_eq!(request_context.service_tier, Some(ServiceTier::priority()));
shutdown_test_codex(&test).await?;
Ok(())
@@ -1,10 +1,12 @@
use codex_models_manager::model_info::BASE_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
@@ -46,7 +48,11 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo {
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
additional_speed_tiers: vec!["fast".to_string()],
service_tiers: vec![ModelServiceTier {
id: ServiceTier::priority(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
}],
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
@@ -86,7 +92,7 @@ fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
+16 -8
View File
@@ -82,8 +82,12 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [
"fast"
"service_tiers": [
{
"id": "priority",
"name": "Fast",
"description": "Fast tier"
}
],
"supports_reasoning_summaries": true
},
@@ -164,8 +168,12 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [
"fast"
"service_tiers": [
{
"id": "priority",
"name": "Fast",
"description": "Fast tier"
}
],
"supports_reasoning_summaries": true
},
@@ -249,7 +257,7 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [],
"service_tiers": [],
"supports_reasoning_summaries": true
},
{
@@ -332,7 +340,7 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [],
"service_tiers": [],
"supports_reasoning_summaries": true
},
{
@@ -411,7 +419,7 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [],
"service_tiers": [],
"supports_reasoning_summaries": true
},
{
@@ -491,7 +499,7 @@
"team"
],
"supports_search_tool": true,
"additional_speed_tiers": [],
"service_tiers": [],
"supports_reasoning_summaries": true
}
]
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
visibility: ModelVisibility::None,
supported_in_api: true,
priority: 99,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
+87 -6
View File
@@ -347,12 +347,93 @@ impl From<WebSearchToolConfig> for WebSearchConfig {
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ServiceTier {
Fast,
Flex,
pub const SERVICE_TIER_PRIORITY: &str = "priority";
pub const SERVICE_TIER_FLEX: &str = "flex";
pub const SERVICE_TIER_ULTRAFAST: &str = "ultrafast";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, TS)]
#[serde(transparent)]
#[ts(type = "string")]
pub struct ServiceTier(String);
impl ServiceTier {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn priority() -> Self {
Self::new(SERVICE_TIER_PRIORITY)
}
pub fn flex() -> Self {
Self::new(SERVICE_TIER_FLEX)
}
pub fn ultrafast() -> Self {
Self::new(SERVICE_TIER_ULTRAFAST)
}
pub fn is_priority(&self) -> bool {
self.as_str() == SERVICE_TIER_PRIORITY
}
pub fn is_flex(&self) -> bool {
self.as_str() == SERVICE_TIER_FLEX
}
}
impl AsRef<str> for ServiceTier {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl From<&str> for ServiceTier {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for ServiceTier {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<ServiceTier> for String {
fn from(value: ServiceTier) -> Self {
value.0
}
}
impl std::fmt::Display for ServiceTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl JsonSchema for ServiceTier {
fn schema_name() -> String {
"ServiceTier".to_string()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
metadata: Some(Box::new(Metadata {
description: Some(
"String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed."
.to_string(),
),
..Default::default()
})),
..Default::default()
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
+28 -11
View File
@@ -17,10 +17,10 @@ use ts_rs::TS;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary;
use crate::config_types::ServiceTier;
use crate::config_types::Verbosity;
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
pub const SPEED_TIER_FAST: &str = "fast";
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
@@ -115,6 +115,13 @@ pub struct ModelAvailabilityNux {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct ModelServiceTier {
pub id: ServiceTier,
pub name: String,
pub description: String,
}
/// Metadata describing a Codex-supported model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelPreset {
@@ -133,9 +140,9 @@ pub struct ModelPreset {
/// Whether this model supports personality-specific instructions.
#[serde(default)]
pub supports_personality: bool,
/// Additional speed tiers this model can run with beyond the standard path.
/// User-selectable service tiers supported by this model.
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
pub service_tiers: Vec<ModelServiceTier>,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
@@ -257,7 +264,7 @@ pub struct ModelInfo {
pub supported_in_api: bool,
pub priority: i32,
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
pub service_tiers: Vec<ModelServiceTier>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
@@ -441,7 +448,7 @@ impl From<ModelInfo> for ModelPreset {
.unwrap_or(ReasoningEffort::None),
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
supports_personality,
additional_speed_tiers: info.additional_speed_tiers,
service_tiers: info.service_tiers,
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
@@ -463,10 +470,16 @@ impl From<ModelInfo> for ModelPreset {
}
impl ModelPreset {
pub fn supports_fast_mode(&self) -> bool {
self.additional_speed_tiers
pub fn supports_service_tier(&self, service_tier: &ServiceTier) -> bool {
self.service_tiers
.iter()
.any(|tier| tier == SPEED_TIER_FAST)
.any(|tier| tier.id == *service_tier)
}
pub fn service_tier(&self, service_tier: &ServiceTier) -> Option<&ModelServiceTier> {
self.service_tiers
.iter()
.find(|tier| tier.id == *service_tier)
}
/// Filter models based on authentication mode.
@@ -547,7 +560,7 @@ mod tests {
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
@@ -817,7 +830,11 @@ mod tests {
availability_nux: Some(ModelAvailabilityNux {
message: "Try Spark.".to_string(),
}),
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
service_tiers: vec![ModelServiceTier {
id: ServiceTier::priority(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
}],
..test_model(/*spec*/ None)
});
@@ -827,6 +844,6 @@ mod tests {
message: "Try Spark.".to_string(),
})
);
assert!(preset.supports_fast_mode());
assert!(preset.supports_service_tier(&ServiceTier::priority()));
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
description: "Balanced".to_string(),
}],
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
is_default: false,
upgrade: None,
show_in_picker,
+14 -13
View File
@@ -1163,25 +1163,26 @@ 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"
} else {
"off"
let mut message = match service_tier.as_ref() {
Some(service_tier) => format!(
"Service tier set to {}",
self.chat_widget.service_tier_display_name(
self.chat_widget.current_model(),
service_tier,
)
),
None => "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);
@@ -1190,14 +1191,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}"
));
}
}
+3 -3
View File
@@ -3662,7 +3662,7 @@ async fn clear_ui_header_shows_fast_status_for_fast_capable_models() {
app.chat_widget
.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
app.chat_widget
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority()));
set_chatgpt_auth(&mut app.chat_widget);
set_fast_mode_test_catalog(&mut app.chat_widget);
@@ -4280,13 +4280,13 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() {
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));
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::priority()));
let config = app.fresh_session_config();
assert_eq!(
config.service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)
Some(codex_protocol::config_types::ServiceTier::priority())
);
}
+1 -1
View File
@@ -1073,7 +1073,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
})
.collect(),
supports_personality: model.supports_personality,
additional_speed_tiers: model.additional_speed_tiers,
service_tiers: model.service_tiers.into_iter().map(Into::into).collect(),
is_default: model.is_default,
upgrade,
show_in_picker: !model.hidden,
+80 -54
View File
@@ -184,6 +184,8 @@ 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::SlashCommandAction;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::key_hint::KeyBindingListExt;
@@ -262,13 +264,13 @@ 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),
Command(SlashCommandAction),
/// An inline slash command and its trimmed argument text.
///
/// The `TextElement` ranges are rebased into the argument string, while any pending local
/// command-history entry still represents the original command invocation that should be
/// committed only if dispatch accepts it.
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
CommandWithArgs(SlashCommandAction, String, Vec<TextElement>),
None,
}
@@ -386,6 +388,7 @@ pub(crate) struct ChatComposer {
goal_status_indicator: Option<GoalStatusIndicator>,
connectors_enabled: bool,
plugins_command_enabled: bool,
service_tier_commands: Vec<ServiceTierCommand>,
fast_command_enabled: bool,
goal_command_enabled: bool,
personality_command_enabled: bool,
@@ -573,6 +576,7 @@ impl ChatComposer {
goal_status_indicator: None,
connectors_enabled: false,
plugins_command_enabled: false,
service_tier_commands: Vec::new(),
fast_command_enabled: false,
goal_command_enabled: false,
personality_command_enabled: false,
@@ -646,6 +650,11 @@ impl ChatComposer {
self.plugins_command_enabled = enabled;
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.service_tier_commands = commands;
self.sync_popups();
}
/// Toggle composer-side image paste handling.
///
/// This only affects whether image-like paste content is converted into attachments; the
@@ -1634,13 +1643,10 @@ 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
});
let selected_cmd = popup.selected_item();
if let Some(cmd) = selected_cmd {
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
if cmd.as_builtin() == Some(SlashCommand::Skills) {
self.stage_selected_slash_command_history(&cmd);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
@@ -1672,11 +1678,7 @@ 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 {
if let Some(cmd) = popup.selected_item() {
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
@@ -1696,9 +1698,8 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
self.stage_selected_slash_command_history(cmd);
if let Some(cmd) = popup.selected_item() {
self.stage_selected_slash_command_history(&cmd);
self.textarea.set_text_clearing_elements("");
self.is_bash_mode = false;
return (InputResult::Command(cmd), true);
@@ -2496,10 +2497,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 {
if slash_commands::find_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
.is_none()
{
let message = format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
);
@@ -2700,10 +2704,13 @@ impl ChatComposer {
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.builtin_command_flags())
&& let Some(cmd) = slash_commands::find_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
{
if self.reject_slash_command_if_unavailable(cmd) {
if self.reject_slash_command_if_unavailable(&cmd) {
self.stage_slash_command_history();
self.record_pending_slash_command_history();
return Some(InputResult::None);
@@ -2733,12 +2740,16 @@ impl ChatComposer {
return None;
}
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
let cmd = slash_commands::find_command(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)?;
if !cmd.supports_inline_args() {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
if self.reject_slash_command_if_unavailable(&cmd) {
self.stage_slash_command_history();
self.record_pending_slash_command_history();
return Some(InputResult::None);
@@ -2783,7 +2794,7 @@ impl ChatComposer {
Some((trimmed_rest.to_string(), args_elements))
}
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
fn reject_slash_command_if_unavailable(&self, cmd: &SlashCommandAction) -> bool {
if !self.is_task_running || cmd.available_during_task() {
return false;
}
@@ -2828,7 +2839,7 @@ 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) {
fn stage_selected_slash_command_history(&mut self, cmd: &SlashCommandAction) {
self.stage_slash_command_history_text(format!("/{}", cmd.command()));
}
@@ -3545,7 +3556,12 @@ impl ChatComposer {
}
fn is_known_slash_name(&self, name: &str) -> bool {
slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some()
slash_commands::find_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,
@@ -3577,7 +3593,7 @@ impl ChatComposer {
}
/// Heuristic for whether the typed slash command looks like a valid
/// prefix for any known built-in command.
/// prefix for any known command.
/// Empty names only count when there is no extra content after the '/'.
fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool {
if !self.slash_commands_enabled() {
@@ -3587,7 +3603,11 @@ impl ChatComposer {
return rest_after_name.is_empty();
}
slash_commands::has_builtin_prefix(name, self.builtin_command_flags())
slash_commands::has_command_prefix(
name,
self.builtin_command_flags(),
&self.service_tier_commands,
)
}
/// Synchronize `self.command_popup` with the current text in the
@@ -3638,18 +3658,21 @@ impl ChatComposer {
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,
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,
},
&self.service_tier_commands,
);
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
}
@@ -6416,7 +6439,10 @@ mod tests {
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Command(SlashCommand::Diff)));
assert!(matches!(
result,
InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff))
));
}
/// Behavior: if a burst is buffering text and the user presses a non-char key, flush the
@@ -6883,7 +6909,6 @@ mod tests {
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
@@ -6897,9 +6922,7 @@ mod tests {
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(command) => assert_eq!(command.command(), "model"),
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
@@ -6936,7 +6959,6 @@ mod tests {
#[test]
fn slash_popup_resume_for_res_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
@@ -6950,9 +6972,7 @@ mod tests {
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "resume")
}
Some(command) => assert_eq!(command.command(), "resume"),
None => panic!("no selected command for '/res'"),
},
_ => panic!("slash popup not active after typing '/res'"),
@@ -9083,7 +9103,10 @@ mod tests {
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
assert_eq!(
result,
InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff))
);
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
@@ -9108,7 +9131,10 @@ mod tests {
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
assert_eq!(
result,
InputResult::Command(SlashCommandAction::Builtin(SlashCommand::Diff))
);
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
@@ -9137,7 +9163,7 @@ mod tests {
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd, SlashCommand::Plan);
assert_eq!(cmd.command(), "plan");
assert_eq!(args, "investigate this");
assert!(text_elements.is_empty());
}
+123 -120
View File
@@ -10,6 +10,8 @@ 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::ServiceTierCommand;
use super::slash_commands::SlashCommandAction;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
@@ -24,14 +26,11 @@ const COMMAND_COLUMN_WIDTH: ColumnWidthConfig = ColumnWidthConfig::new(
);
/// A selectable item in the popup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
}
pub(crate) type CommandItem = SlashCommandAction;
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
commands: Vec<CommandItem>,
state: ScrollState,
}
@@ -67,17 +66,19 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
}
impl CommandPopup {
pub(crate) fn new(flags: CommandPopupFlags) -> 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();
pub(crate) fn new(
flags: CommandPopupFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Self {
// Keep command availability in sync with the composer.
let commands = slash_commands::commands_for_input(flags.into(), service_tier_commands)
.into_iter()
.filter(|command| !command.command().starts_with("debug"))
.filter(|command| command.as_builtin() != Some(SlashCommand::Apps))
.collect();
Self {
command_filter: String::new(),
builtins,
commands,
state: ScrollState::new(),
}
}
@@ -127,18 +128,20 @@ impl CommandPopup {
)
}
/// Compute exact/prefix matches over built-in commands and user prompts,
/// paired with optional highlight indices. Preserves the original
/// presentation order for built-ins and prompts.
/// Compute exact/prefix matches over visible commands, paired with
/// optional highlight indices. Preserves the original presentation order.
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>)> {
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 command
.as_builtin()
.is_some_and(|builtin| ALIAS_COMMANDS.contains(&builtin))
{
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
out.push((command.clone(), None));
}
return out;
}
@@ -170,8 +173,8 @@ impl CommandPopup {
}
};
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
for command in self.commands.iter() {
push_match(command.clone(), command.command(), None, 0);
}
out.extend(exact);
@@ -190,9 +193,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(),
@@ -228,7 +230,7 @@ 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())
}
}
@@ -256,7 +258,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(), &[]);
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
@@ -264,9 +266,7 @@ mod tests {
// Access the filtered list via the selected command and ensure that
// one of the matches is the new "init" command.
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
});
let has_init = matches.iter().any(|item| item.command() == "init");
assert!(
has_init,
"expected '/init' to appear among filtered commands"
@@ -275,55 +275,51 @@ mod tests {
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]);
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
// command by default.
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(command) => assert_eq!(command.command(), "init"),
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(), &[]);
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(command) => assert_eq!(command.command(), "model"),
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]);
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]);
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default(), &[]);
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert!(
!cmds.contains(&"compact"),
@@ -333,7 +329,7 @@ mod tests {
#[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(), &[]);
popup.on_composer_text_change("/".to_string());
let items = popup.filtered_items();
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
@@ -345,15 +341,13 @@ 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(), &[]);
popup.on_composer_text_change("/".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert!(
!cmds.contains(&"collab"),
@@ -367,70 +361,77 @@ mod tests {
#[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,
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,
},
&[],
);
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"),
Some(command) => assert_eq!(command.command(), "collab"),
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,
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,
},
&[],
);
popup.on_composer_text_change("/plan".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"),
Some(command) => assert_eq!(command.command(), "plan"),
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,
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,
},
&[],
);
popup.on_composer_text_change("/pers".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert!(
!cmds.contains(&"personality"),
@@ -440,48 +441,52 @@ mod tests {
#[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,
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,
},
&[],
);
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"),
Some(command) => assert_eq!(command.command(), "personality"),
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,
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,
},
&[],
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert!(
@@ -492,13 +497,11 @@ mod tests {
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(CommandPopupFlags::default());
let popup = CommandPopup::new(CommandPopupFlags::default(), &[]);
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.map(|item| item.command())
.collect();
assert!(
+6
View File
@@ -137,6 +137,7 @@ mod textarea;
mod unified_exec_footer;
pub(crate) use feedback_view::FeedbackNoteView;
pub(crate) use selection_tabs::SelectionTab;
use slash_commands::ServiceTierCommand;
/// How long the "press again to quit" hint stays visible.
///
@@ -386,6 +387,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) {
self.composer.set_service_tier_commands(commands);
self.request_redraw();
}
pub fn set_goal_command_enabled(&mut self, enabled: bool) {
self.composer.set_goal_command_enabled(enabled);
self.request_redraw();
+275 -39
View File
@@ -1,10 +1,14 @@
//! Shared helpers for filtering and matching built-in slash commands.
//! Shared helpers for filtering and matching 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
//! and ensures they stay in sync.
use std::collections::HashSet;
use std::str::FromStr;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelServiceTier;
use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
@@ -24,6 +28,75 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) side_conversation_active: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ServiceTierCommand {
pub(crate) service_tier: ServiceTier,
pub(crate) command: String,
pub(crate) name: String,
pub(crate) description: String,
}
impl ServiceTierCommand {
fn from_model_service_tier(service_tier: &ModelServiceTier, command: String) -> Self {
Self {
service_tier: service_tier.id.clone(),
command,
name: service_tier.name.clone(),
description: service_tier.description.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SlashCommandAction {
Builtin(SlashCommand),
ServiceTier(ServiceTierCommand),
}
impl SlashCommandAction {
pub(crate) fn command(&self) -> &str {
match self {
Self::Builtin(command) => command.command(),
Self::ServiceTier(command) => command.command.as_str(),
}
}
pub(crate) fn description(&self) -> &str {
match self {
Self::Builtin(command) => command.description(),
Self::ServiceTier(command) => command.description.as_str(),
}
}
pub(crate) fn supports_inline_args(&self) -> bool {
match self {
Self::Builtin(command) => command.supports_inline_args(),
Self::ServiceTier(_) => true,
}
}
pub(crate) fn available_in_side_conversation(&self) -> bool {
match self {
Self::Builtin(command) => command.available_in_side_conversation(),
Self::ServiceTier(_) => false,
}
}
pub(crate) fn available_during_task(&self) -> bool {
match self {
Self::Builtin(command) => command.available_during_task(),
Self::ServiceTier(_) => false,
}
}
pub(crate) fn as_builtin(&self) -> Option<SlashCommand> {
match self {
Self::Builtin(command) => Some(*command),
Self::ServiceTier(_) => None,
}
}
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
@@ -35,7 +108,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,31 +116,131 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
.collect()
}
/// Find a single built-in command by exact name, after applying feature gating.
fn visible_service_tier_commands(
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Vec<ServiceTierCommand> {
if !flags.fast_command_enabled || flags.side_conversation_active {
return Vec::new();
}
service_tier_commands.to_vec()
}
pub(crate) fn commands_for_input(
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Vec<SlashCommandAction> {
let visible_service_tier_commands = visible_service_tier_commands(flags, service_tier_commands);
let mut commands = Vec::new();
for (_, command) in builtins_for_input(flags) {
commands.push(SlashCommandAction::Builtin(command));
if command == SlashCommand::Model {
commands.extend(
visible_service_tier_commands
.iter()
.cloned()
.map(SlashCommandAction::ServiceTier),
);
}
}
commands
}
/// Find a single slash command by exact name, after applying feature gating.
///
/// Side-conversation gating is intentionally enforced by dispatch rather than exact lookup so a
/// typed command can produce a side-specific unavailable message while the popup still hides it.
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
let cmd = SlashCommand::from_str(name).ok()?;
builtins_for_input(BuiltinCommandFlags {
pub(crate) fn find_command(
name: &str,
flags: BuiltinCommandFlags,
service_tier_commands: &[ServiceTierCommand],
) -> Option<SlashCommandAction> {
let lookup_flags = BuiltinCommandFlags {
side_conversation_active: false,
..flags
})
.into_iter()
.any(|(_, visible_cmd)| visible_cmd == cmd)
.then_some(cmd)
};
if let Some(command) = SlashCommand::from_str(name).ok().filter(|command| {
builtins_for_input(lookup_flags)
.into_iter()
.any(|(_, visible_command)| visible_command == *command)
}) {
return Some(SlashCommandAction::Builtin(command));
}
visible_service_tier_commands(lookup_flags, service_tier_commands)
.into_iter()
.find(|command| command.command == name)
.map(SlashCommandAction::ServiceTier)
}
/// 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)
/// Whether any visible slash command fuzzily matches the provided prefix.
pub(crate) fn has_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())
}
pub(crate) fn service_tier_commands_for_model(model: &ModelPreset) -> Vec<ServiceTierCommand> {
let mut reserved_names = HashSet::new();
let mut commands = Vec::new();
for service_tier in &model.service_tiers {
let Some(command) = command_token_for_service_tier(service_tier, &reserved_names) else {
continue;
};
reserved_names.insert(command.clone());
commands.push(ServiceTierCommand::from_model_service_tier(
service_tier,
command,
));
}
commands
}
fn command_token_for_service_tier(
service_tier: &ModelServiceTier,
reserved_names: &HashSet<String>,
) -> Option<String> {
[
normalize_service_tier_command_token(service_tier.name.as_str()),
Some(service_tier.id.to_string()),
]
.into_iter()
.flatten()
.find(|candidate| !command_token_conflicts(candidate, reserved_names))
}
fn command_token_conflicts(candidate: &str, reserved_names: &HashSet<String>) -> bool {
candidate.is_empty()
|| reserved_names.contains(candidate)
|| SlashCommand::from_str(candidate).is_ok()
}
fn normalize_service_tier_command_token(name: &str) -> Option<String> {
let mut normalized = String::new();
let mut pending_dash = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
if pending_dash && !normalized.is_empty() {
normalized.push('-');
}
normalized.push(ch.to_ascii_lowercase());
pending_dash = false;
} else if !normalized.is_empty() {
pending_dash = true;
}
}
(!normalized.is_empty()).then_some(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::openai_models::ModelServiceTier;
use pretty_assertions::assert_eq;
fn all_enabled_flags() -> BuiltinCommandFlags {
@@ -88,53 +260,62 @@ mod tests {
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", all_enabled_flags());
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
let cmd = find_command("debug-config", all_enabled_flags(), &[]);
assert_eq!(
cmd,
Some(SlashCommandAction::Builtin(SlashCommand::DebugConfig))
);
}
#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", all_enabled_flags()),
Some(SlashCommand::Clear)
find_command("clear", all_enabled_flags(), &[]),
Some(SlashCommandAction::Builtin(SlashCommand::Clear))
);
}
#[test]
fn stop_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("stop", all_enabled_flags()),
Some(SlashCommand::Stop)
find_command("stop", all_enabled_flags(), &[]),
Some(SlashCommandAction::Builtin(SlashCommand::Stop))
);
}
#[test]
fn clean_command_alias_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clean", all_enabled_flags()),
Some(SlashCommand::Stop)
find_command("clean", all_enabled_flags(), &[]),
Some(SlashCommandAction::Builtin(SlashCommand::Stop))
);
}
#[test]
fn fast_command_is_hidden_when_disabled() {
fn service_tier_command_is_hidden_when_disabled() {
let service_tier_commands = vec![ServiceTierCommand {
service_tier: ServiceTier::priority(),
command: "fast".to_string(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
}];
let mut flags = all_enabled_flags();
flags.fast_command_enabled = false;
assert_eq!(find_builtin_command("fast", flags), None);
assert_eq!(find_command("fast", flags, &service_tier_commands), None);
}
#[test]
fn goal_command_is_hidden_when_disabled() {
let mut flags = all_enabled_flags();
flags.goal_command_enabled = false;
assert_eq!(find_builtin_command("goal", flags), None);
assert_eq!(find_command("goal", flags, &[]), None);
}
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
assert_eq!(find_builtin_command("realtime", flags), None);
assert_eq!(find_command("realtime", flags, &[]), None);
}
#[test]
@@ -142,33 +323,36 @@ mod tests {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
assert_eq!(find_command("settings", flags, &[]), None);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
let mut flags = all_enabled_flags();
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
assert_eq!(find_command("settings", flags, &[]), None);
}
#[test]
fn side_conversation_hides_commands_without_side_flag() {
let commands = builtins_for_input(BuiltinCommandFlags {
side_conversation_active: true,
..all_enabled_flags()
})
let commands = commands_for_input(
BuiltinCommandFlags {
side_conversation_active: true,
..all_enabled_flags()
},
&[],
)
.into_iter()
.map(|(_, command)| command)
.map(|command| command.command().to_string())
.collect::<Vec<_>>();
assert_eq!(
commands,
vec![
SlashCommand::Copy,
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
"copy".to_string(),
"diff".to_string(),
"mention".to_string(),
"status".to_string(),
]
);
}
@@ -176,14 +360,66 @@ mod tests {
#[test]
fn side_conversation_exact_lookup_still_resolves_hidden_commands_for_dispatch_error() {
assert_eq!(
find_builtin_command(
find_command(
"review",
BuiltinCommandFlags {
side_conversation_active: true,
..all_enabled_flags()
},
&[],
),
Some(SlashCommand::Review)
Some(SlashCommandAction::Builtin(SlashCommand::Review))
);
}
#[test]
fn service_tier_command_uses_name_then_id_on_collision() {
let model = ModelPreset {
id: "test".to_string(),
model: "test".to_string(),
display_name: "Test".to_string(),
description: String::new(),
default_reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium,
supported_reasoning_efforts: Vec::new(),
supports_personality: false,
service_tiers: vec![
ModelServiceTier {
id: ServiceTier::priority(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
},
ModelServiceTier {
id: ServiceTier::ultrafast(),
name: "Model".to_string(),
description: "Ultrafast tier".to_string(),
},
],
is_default: false,
upgrade: None,
show_in_picker: true,
availability_nux: None,
supported_in_api: true,
input_modalities: Vec::new(),
};
let commands = service_tier_commands_for_model(&model);
assert_eq!(
commands,
vec![
ServiceTierCommand {
service_tier: ServiceTier::priority(),
command: "fast".to_string(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
},
ServiceTierCommand {
service_tier: ServiceTier::ultrafast(),
command: "ultrafast".to_string(),
name: "Model".to_string(),
description: "Ultrafast tier".to_string(),
},
]
);
}
}
@@ -106,7 +106,7 @@ pub(crate) enum StatusLineItem {
/// Full session UUID.
SessionId,
/// Whether Fast mode is currently active.
/// Current service-tier selection.
FastMode,
/// Current thread title (if set by user).
@@ -148,7 +148,7 @@ impl StatusLineItem {
StatusLineItem::SessionId => {
"Current session identifier (omitted until session starts)"
}
StatusLineItem::FastMode => "Whether Fast mode is currently active",
StatusLineItem::FastMode => "Current service tier (or default when unset)",
StatusLineItem::ThreadTitle => "Current thread title (omitted when unavailable)",
StatusLineItem::TaskProgress => {
"Latest task progress from update_plan (omitted until available)"
@@ -47,7 +47,7 @@ impl StatusSurfacePreviewItem {
StatusSurfacePreviewItem::TotalInputTokens => "0 in",
StatusSurfacePreviewItem::TotalOutputTokens => "0 out",
StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4",
StatusSurfacePreviewItem::FastMode => "Fast on",
StatusSurfacePreviewItem::FastMode => "Tier Fast",
StatusSurfacePreviewItem::Model => "gpt-5.2-codex",
StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium",
StatusSurfacePreviewItem::TaskProgress => "Tasks 0/0",
+2 -2
View File
@@ -73,7 +73,7 @@ pub(crate) enum TerminalTitleItem {
TotalOutputTokens,
/// Full session UUID.
SessionId,
/// Whether Fast mode is currently active.
/// Current service-tier selection.
FastMode,
/// Current model name.
#[strum(to_string = "model", serialize = "model-name")]
@@ -117,7 +117,7 @@ impl TerminalTitleItem {
TerminalTitleItem::SessionId => {
"Current session identifier (omitted until session starts)"
}
TerminalTitleItem::FastMode => "Whether Fast mode is currently active",
TerminalTitleItem::FastMode => "Current service tier (or default when unset)",
TerminalTitleItem::Model => "Current model name",
TerminalTitleItem::ModelWithReasoning => "Current model name with reasoning level",
TerminalTitleItem::TaskProgress => {
+61 -35
View File
@@ -57,6 +57,7 @@ use crate::bottom_pane::StatusSurfacePreviewData;
use crate::bottom_pane::StatusSurfacePreviewItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::Constrained;
@@ -2364,7 +2365,7 @@ impl ChatWidget {
self.current_rollout_path = event.rollout_path.clone();
self.current_cwd = Some(event.cwd.to_path_buf());
self.config.cwd = event.cwd.clone();
self.effective_service_tier = event.service_tier;
self.effective_service_tier = event.service_tier.clone();
if let Err(err) = self
.config
.permissions
@@ -2406,6 +2407,7 @@ impl ChatWidget {
self.refresh_model_display();
self.refresh_status_surfaces();
self.sync_fast_command_enabled();
self.sync_service_tier_commands();
self.sync_personality_command_enabled();
self.sync_plugins_command_enabled();
self.sync_goal_command_enabled();
@@ -2413,7 +2415,7 @@ impl ChatWidget {
if display == SessionConfiguredDisplay::Normal {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status =
self.should_show_fast_status(&model_for_header, event.service_tier);
self.should_show_fast_status(&model_for_header, event.service_tier.clone());
#[cfg(test)]
let initial_messages = event.initial_messages.clone();
let session_info_cell = history_cell::new_session_info(
@@ -5270,7 +5272,7 @@ impl ChatWidget {
let active_cell = Some(Self::placeholder_session_header_cell(&config));
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier;
let effective_service_tier = config.service_tier.clone();
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
@@ -5451,6 +5453,7 @@ impl ChatWidget {
.bottom_pane
.set_collaboration_modes_enabled(/*enabled*/ true);
widget.sync_fast_command_enabled();
widget.sync_service_tier_commands();
widget.sync_personality_command_enabled();
widget.sync_plugins_command_enabled();
widget.sync_goal_command_enabled();
@@ -6236,7 +6239,7 @@ impl ChatWidget {
.personality
.filter(|_| self.config.features.enabled(Feature::Personality))
.filter(|_| self.current_model_supports_personality());
let service_tier = match self.config.service_tier {
let service_tier = match self.config.service_tier.clone() {
Some(service_tier) => Some(Some(service_tier)),
None if self.config.notices.fast_default_opt_out == Some(true) => Some(None),
None => None,
@@ -10358,18 +10361,18 @@ impl ChatWidget {
self.config.personality = Some(personality);
}
/// Set Fast mode in the widget's config copy.
/// Set the selected service tier in the widget's config copy.
pub(crate) fn set_service_tier(&mut self, service_tier: Option<ServiceTier>) {
self.config.service_tier = service_tier;
self.config.service_tier = service_tier.clone();
self.effective_service_tier = service_tier;
}
pub(crate) fn current_service_tier(&self) -> Option<ServiceTier> {
self.effective_service_tier
self.effective_service_tier.clone()
}
pub(crate) fn configured_service_tier(&self) -> Option<ServiceTier> {
self.config.service_tier
self.config.service_tier.clone()
}
pub(crate) fn fast_default_opt_out(&self) -> Option<bool> {
@@ -10412,7 +10415,7 @@ impl ChatWidget {
service_tier: Option<ServiceTier>,
) -> bool {
self.model_supports_fast_mode(model)
&& matches!(service_tier, Some(ServiceTier::Fast))
&& service_tier.as_ref().is_some_and(ServiceTier::is_priority)
&& self.has_chatgpt_account
}
@@ -10505,6 +10508,11 @@ impl ChatWidget {
.set_fast_command_enabled(self.fast_mode_enabled());
}
fn sync_service_tier_commands(&mut self) {
self.bottom_pane
.set_service_tier_commands(self.available_service_tier_commands());
}
fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
@@ -10520,30 +10528,55 @@ impl ChatWidget {
.set_goal_command_enabled(self.config.features.enabled(Feature::Goals));
}
fn current_model_supports_personality(&self) -> bool {
let model = self.current_model();
fn model_preset(&self, model: &str) -> Option<ModelPreset> {
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.supports_personality)
.and_then(|models| models.into_iter().find(|preset| preset.model == model))
}
fn available_service_tier_commands(&self) -> Vec<ServiceTierCommand> {
self.model_preset(self.current_model())
.map(|model| {
crate::bottom_pane::slash_commands::service_tier_commands_for_model(&model)
})
.unwrap_or_default()
}
pub(crate) fn service_tier_display_name(
&self,
model: &str,
service_tier: &ServiceTier,
) -> String {
self.model_preset(model)
.and_then(|preset| {
preset
.service_tier(service_tier)
.map(|tier| tier.name.clone())
})
.unwrap_or_else(|| service_tier.to_string())
}
pub(crate) fn current_service_tier_name(&self) -> Option<String> {
self.current_service_tier()
.map(|tier| self.service_tier_display_name(self.current_model(), &tier))
}
pub(crate) fn current_service_tier_status_label(&self) -> String {
self.current_service_tier_name()
.map(|name| format!("Tier {name}"))
.unwrap_or_else(|| "Tier default".to_string())
}
fn current_model_supports_personality(&self) -> bool {
self.model_preset(self.current_model())
.map(|preset| preset.supports_personality)
.unwrap_or(false)
}
fn model_supports_fast_mode(&self, model: &str) -> bool {
self.model_catalog
.try_list_models()
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.supports_fast_mode())
})
self.model_preset(model)
.map(|preset| preset.supports_service_tier(&ServiceTier::priority()))
.unwrap_or(false)
}
@@ -10552,16 +10585,8 @@ impl ChatWidget {
/// We intentionally default to `true` when model metadata cannot be read so transient catalog
/// failures do not hard-block user input in the UI.
fn current_model_supports_images(&self) -> bool {
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.input_modalities.contains(&InputModality::Image))
})
self.model_preset(self.current_model())
.map(|preset| preset.input_modalities.contains(&InputModality::Image))
.unwrap_or(true)
}
@@ -10699,6 +10724,7 @@ impl ChatWidget {
/// (`refresh_status_line`).
fn refresh_model_dependent_surfaces(&mut self) {
self.refresh_model_display();
self.sync_service_tier_commands();
self.refresh_status_line();
}
+160 -155
View File
@@ -9,6 +9,7 @@ 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::SlashCommandAction;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
@@ -38,9 +39,10 @@ impl ChatWidget {
/// The composer stages history before returning `InputResult::Command`; this wrapper commits
/// that staged entry after dispatch so slash-command recall follows the same "submitted input"
/// rule as normal text.
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) {
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommandAction) {
let is_goal = cmd.as_builtin() == Some(SlashCommand::Goal);
self.dispatch_command(cmd);
if cmd == SlashCommand::Goal {
if is_goal {
self.bottom_pane.drain_pending_submission_state();
}
self.bottom_pane.record_pending_slash_command_history();
@@ -53,7 +55,7 @@ impl ChatWidget {
/// the only input-result entry point avoids double-recording commands with inline args.
pub(super) fn handle_slash_command_with_args_dispatch(
&mut self,
cmd: SlashCommand,
cmd: SlashCommandAction,
args: String,
text_elements: Vec<TextElement>,
) {
@@ -103,11 +105,11 @@ impl ChatWidget {
self.request_side_conversation(parent_thread_id, /*user_message*/ None);
}
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
pub(super) fn dispatch_command(&mut self, cmd: SlashCommandAction) {
if !self.ensure_slash_command_allowed_in_side_conversation(&cmd) {
return;
}
if !self.ensure_side_command_allowed_outside_review(cmd) {
if !self.ensure_side_command_allowed_outside_review(&cmd) {
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
@@ -122,32 +124,40 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Feedback => {
SlashCommandAction::ServiceTier(command) => {
let next_tier =
if self.current_service_tier().as_ref() == Some(&command.service_tier) {
None
} else {
Some(command.service_tier)
};
self.set_service_tier_selection(next_tier);
}
SlashCommandAction::Builtin(SlashCommand::Feedback) => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
}
SlashCommand::New => {
SlashCommandAction::Builtin(SlashCommand::New) => {
self.app_event_tx.send(AppEvent::NewSession);
}
SlashCommand::Clear => {
SlashCommandAction::Builtin(SlashCommand::Clear) => {
self.app_event_tx.send(AppEvent::ClearUi);
}
SlashCommand::Resume => {
SlashCommandAction::Builtin(SlashCommand::Resume) => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
SlashCommandAction::Builtin(SlashCommand::Fork) => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
SlashCommandAction::Builtin(SlashCommand::Init) => {
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
if init_target.exists() {
let message = format!(
@@ -159,33 +169,25 @@ impl ChatWidget {
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
SlashCommandAction::Builtin(SlashCommand::Compact) => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
SlashCommandAction::Builtin(SlashCommand::Review) => {
self.open_review_popup();
}
SlashCommand::Rename => {
SlashCommandAction::Builtin(SlashCommand::Rename) => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
self.show_rename_prompt();
}
SlashCommand::Model => {
SlashCommandAction::Builtin(SlashCommand::Model) => {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
SlashCommand::Realtime => {
SlashCommandAction::Builtin(SlashCommand::Realtime) => {
if !self.realtime_conversation_enabled() {
return;
}
@@ -195,19 +197,19 @@ impl ChatWidget {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
SlashCommandAction::Builtin(SlashCommand::Settings) => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
SlashCommandAction::Builtin(SlashCommand::Personality) => {
self.open_personality_popup();
}
SlashCommand::Plan => {
SlashCommandAction::Builtin(SlashCommand::Plan) => {
self.apply_plan_slash_command();
}
SlashCommand::Goal => {
SlashCommandAction::Builtin(SlashCommand::Goal) => {
if !self.config.features.enabled(Feature::Goals) {
return;
}
@@ -221,7 +223,7 @@ impl ChatWidget {
);
}
}
SlashCommand::Collab => {
SlashCommandAction::Builtin(SlashCommand::Collab) => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
@@ -231,22 +233,21 @@ impl ChatWidget {
}
self.open_collaboration_modes_popup();
}
SlashCommand::Side => {
SlashCommandAction::Builtin(SlashCommand::Side) => {
self.request_empty_side_conversation();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
SlashCommandAction::Builtin(SlashCommand::Agent)
| SlashCommandAction::Builtin(SlashCommand::MultiAgents) => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {
SlashCommandAction::Builtin(SlashCommand::Approvals)
| SlashCommandAction::Builtin(SlashCommand::Permissions) => {
self.open_permissions_popup();
}
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::Keymap => {
SlashCommandAction::Builtin(SlashCommand::Keymap) => {
self.open_keymap_picker();
}
SlashCommand::ElevateSandbox => {
SlashCommandAction::Builtin(SlashCommand::ElevateSandbox) => {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
@@ -255,8 +256,6 @@ impl ChatWidget {
if !windows_degraded_sandbox_enabled
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
@@ -264,8 +263,6 @@ impl ChatWidget {
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
@@ -293,36 +290,33 @@ impl ChatWidget {
#[cfg(not(target_os = "windows"))]
{
let _ = &self.session_telemetry;
// Not supported; on non-Windows this command should never be reachable.
}
}
SlashCommand::SandboxReadRoot => {
SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot) => {
self.add_error_message(
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
);
}
SlashCommand::Experimental => {
SlashCommandAction::Builtin(SlashCommand::Experimental) => {
self.open_experimental_popup();
}
SlashCommand::AutoReview => {
SlashCommandAction::Builtin(SlashCommand::AutoReview) => {
self.open_auto_review_denials_popup();
}
SlashCommand::Memories => {
SlashCommandAction::Builtin(SlashCommand::Memories) => {
self.open_memories_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
SlashCommandAction::Builtin(SlashCommand::Quit)
| SlashCommandAction::Builtin(SlashCommand::Exit) => {
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
SlashCommandAction::Builtin(SlashCommand::Logout) => {
self.app_event_tx.send(AppEvent::Logout);
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
// }
SlashCommand::Copy => {
SlashCommandAction::Builtin(SlashCommand::Copy) => {
self.copy_last_agent_markdown();
}
SlashCommand::Diff => {
SlashCommandAction::Builtin(SlashCommand::Diff) => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
@@ -339,13 +333,13 @@ impl ChatWidget {
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
SlashCommandAction::Builtin(SlashCommand::Mention) => {
self.insert_str("@");
}
SlashCommand::Skills => {
SlashCommandAction::Builtin(SlashCommand::Skills) => {
self.open_skills_menu();
}
SlashCommand::Status => {
SlashCommandAction::Builtin(SlashCommand::Status) => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
@@ -360,40 +354,38 @@ impl ChatWidget {
);
}
}
SlashCommand::DebugConfig => {
SlashCommandAction::Builtin(SlashCommand::DebugConfig) => {
self.add_debug_config_output();
}
SlashCommand::Title => {
SlashCommandAction::Builtin(SlashCommand::Title) => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
SlashCommandAction::Builtin(SlashCommand::Statusline) => {
self.open_status_line_setup();
}
SlashCommand::Theme => {
SlashCommandAction::Builtin(SlashCommand::Theme) => {
self.open_theme_picker();
}
SlashCommand::Ps => {
SlashCommandAction::Builtin(SlashCommand::Ps) => {
self.add_ps_output();
}
SlashCommand::Stop => {
SlashCommandAction::Builtin(SlashCommand::Stop) => {
self.clean_background_terminals();
}
SlashCommand::MemoryDrop => {
SlashCommandAction::Builtin(SlashCommand::MemoryDrop)
| SlashCommandAction::Builtin(SlashCommand::MemoryUpdate) => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::MemoryUpdate => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::Mcp => {
SlashCommandAction::Builtin(SlashCommand::Mcp) => {
self.add_mcp_output(McpServerStatusDetail::ToolsAndAuthOnly);
}
SlashCommand::Apps => {
SlashCommandAction::Builtin(SlashCommand::Apps) => {
self.add_connectors_output();
}
SlashCommand::Plugins => {
SlashCommandAction::Builtin(SlashCommand::Plugins) => {
self.add_plugins_output();
}
SlashCommand::Rollout => {
SlashCommandAction::Builtin(SlashCommand::Rollout) => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
format!("Current rollout path: {}", path.display()),
@@ -406,7 +398,7 @@ impl ChatWidget {
);
}
}
SlashCommand::TestApproval => {
SlashCommandAction::Builtin(SlashCommand::TestApproval) => {
use std::collections::HashMap;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
@@ -447,14 +439,14 @@ impl ChatWidget {
/// path as well would make a single command appear twice during Up-arrow navigation.
pub(super) fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
cmd: SlashCommandAction,
args: String,
text_elements: Vec<TextElement>,
) {
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
if !self.ensure_slash_command_allowed_in_side_conversation(&cmd) {
return;
}
if !self.ensure_side_command_allowed_outside_review(cmd) {
if !self.ensure_side_command_allowed_outside_review(&cmd) {
return;
}
if !cmd.supports_inline_args() {
@@ -535,7 +527,7 @@ impl ChatWidget {
fn dispatch_prepared_command_with_args(
&mut self,
cmd: SlashCommand,
cmd: SlashCommandAction,
prepared: PreparedSlashCommandArgs,
) {
let PreparedSlashCommandArgs {
@@ -547,33 +539,40 @@ impl ChatWidget {
source,
} = prepared;
let trimmed = args.trim();
let is_goal_command = cmd.as_builtin() == Some(SlashCommand::Goal);
match cmd {
SlashCommand::Fast => {
SlashCommandAction::ServiceTier(command) => {
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"on" => self.set_service_tier_selection(Some(command.service_tier.clone())),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status =
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
"on"
} else {
"off"
};
let status = if self.current_service_tier().as_ref()
== Some(&command.service_tier)
{
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
format!("{} service tier is {status}.", command.name),
/*hint*/ None,
);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
self.add_error_message(format!(
"Usage: /{} [on|off|status]",
command.command
));
}
}
}
SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() {
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
},
SlashCommand::Rename if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Mcp) => {
match trimmed.to_ascii_lowercase().as_str() {
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
}
}
SlashCommandAction::Builtin(SlashCommand::Rename) if !trimmed.is_empty() => {
if !self.ensure_thread_rename_allowed() {
return;
}
@@ -585,7 +584,7 @@ impl ChatWidget {
};
self.app_event_tx.set_thread_name(name);
}
SlashCommand::Plan if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Plan) if !trimmed.is_empty() => {
if !self.apply_plan_slash_command() {
return;
}
@@ -606,7 +605,7 @@ impl ChatWidget {
self.queue_user_message(user_message);
}
}
SlashCommand::Goal if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Goal) if !trimmed.is_empty() => {
if !self.config.features.enabled(Feature::Goals) {
return;
}
@@ -687,7 +686,7 @@ impl ChatWidget {
self.bottom_pane.drain_pending_submission_state();
}
}
SlashCommand::Side if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Side) if !trimmed.is_empty() => {
let Some(parent_thread_id) = self.thread_id else {
self.add_error_message(
"'/side' is unavailable before the session starts.".to_string(),
@@ -704,23 +703,23 @@ impl ChatWidget {
);
self.request_side_conversation(parent_thread_id, Some(user_message));
}
SlashCommand::Review if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Review) if !trimmed.is_empty() => {
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom { instructions: args },
user_facing_hint: None,
}));
}
SlashCommand::Resume if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::Resume) if !trimmed.is_empty() => {
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot) if !trimmed.is_empty() => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
}
_ => self.dispatch_command(cmd),
}
if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal {
if source == SlashCommandDispatchSource::Live && !is_goal_command {
self.bottom_pane.drain_pending_submission_state();
}
}
@@ -755,8 +754,11 @@ impl ChatWidget {
return QueueDrain::Stop;
}
let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags())
else {
let Some(cmd) = slash_commands::find_command(
name,
self.builtin_command_flags(),
&self.available_service_tier_commands(),
) else {
self.add_info_message(
format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
@@ -791,7 +793,7 @@ impl ChatWidget {
&text_elements,
);
self.dispatch_prepared_command_with_args(
cmd,
cmd.clone(),
PreparedSlashCommandArgs {
args: trimmed_rest.to_string(),
text_elements: args_elements,
@@ -827,60 +829,60 @@ impl ChatWidget {
}
}
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
fn queued_command_drain_result(&self, cmd: SlashCommandAction) -> QueueDrain {
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
return QueueDrain::Stop;
}
match cmd {
SlashCommand::Fast
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
| SlashCommand::Copy
| SlashCommand::Diff
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review
| SlashCommand::Model
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::Personality
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Collab
| SlashCommand::Side
| SlashCommand::Keymap
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::AutoReview
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme => QueueDrain::Stop,
SlashCommandAction::ServiceTier(_)
| SlashCommandAction::Builtin(SlashCommand::Status)
| SlashCommandAction::Builtin(SlashCommand::DebugConfig)
| SlashCommandAction::Builtin(SlashCommand::Ps)
| SlashCommandAction::Builtin(SlashCommand::Stop)
| SlashCommandAction::Builtin(SlashCommand::MemoryDrop)
| SlashCommandAction::Builtin(SlashCommand::MemoryUpdate)
| SlashCommandAction::Builtin(SlashCommand::Mcp)
| SlashCommandAction::Builtin(SlashCommand::Apps)
| SlashCommandAction::Builtin(SlashCommand::Plugins)
| SlashCommandAction::Builtin(SlashCommand::Rollout)
| SlashCommandAction::Builtin(SlashCommand::Copy)
| SlashCommandAction::Builtin(SlashCommand::Diff)
| SlashCommandAction::Builtin(SlashCommand::Rename)
| SlashCommandAction::Builtin(SlashCommand::TestApproval) => QueueDrain::Continue,
SlashCommandAction::Builtin(SlashCommand::Feedback)
| SlashCommandAction::Builtin(SlashCommand::New)
| SlashCommandAction::Builtin(SlashCommand::Clear)
| SlashCommandAction::Builtin(SlashCommand::Resume)
| SlashCommandAction::Builtin(SlashCommand::Fork)
| SlashCommandAction::Builtin(SlashCommand::Init)
| SlashCommandAction::Builtin(SlashCommand::Compact)
| SlashCommandAction::Builtin(SlashCommand::Review)
| SlashCommandAction::Builtin(SlashCommand::Model)
| SlashCommandAction::Builtin(SlashCommand::Realtime)
| SlashCommandAction::Builtin(SlashCommand::Settings)
| SlashCommandAction::Builtin(SlashCommand::Personality)
| SlashCommandAction::Builtin(SlashCommand::Plan)
| SlashCommandAction::Builtin(SlashCommand::Goal)
| SlashCommandAction::Builtin(SlashCommand::Collab)
| SlashCommandAction::Builtin(SlashCommand::Side)
| SlashCommandAction::Builtin(SlashCommand::Keymap)
| SlashCommandAction::Builtin(SlashCommand::Agent)
| SlashCommandAction::Builtin(SlashCommand::MultiAgents)
| SlashCommandAction::Builtin(SlashCommand::Approvals)
| SlashCommandAction::Builtin(SlashCommand::Permissions)
| SlashCommandAction::Builtin(SlashCommand::ElevateSandbox)
| SlashCommandAction::Builtin(SlashCommand::SandboxReadRoot)
| SlashCommandAction::Builtin(SlashCommand::Experimental)
| SlashCommandAction::Builtin(SlashCommand::AutoReview)
| SlashCommandAction::Builtin(SlashCommand::Memories)
| SlashCommandAction::Builtin(SlashCommand::Quit)
| SlashCommandAction::Builtin(SlashCommand::Exit)
| SlashCommandAction::Builtin(SlashCommand::Logout)
| SlashCommandAction::Builtin(SlashCommand::Mention)
| SlashCommandAction::Builtin(SlashCommand::Skills)
| SlashCommandAction::Builtin(SlashCommand::Title)
| SlashCommandAction::Builtin(SlashCommand::Statusline)
| SlashCommandAction::Builtin(SlashCommand::Theme) => QueueDrain::Stop,
}
}
@@ -909,7 +911,10 @@ impl ChatWidget {
.collect()
}
fn ensure_slash_command_allowed_in_side_conversation(&mut self, cmd: SlashCommand) -> bool {
fn ensure_slash_command_allowed_in_side_conversation(
&mut self,
cmd: &SlashCommandAction,
) -> bool {
if !self.active_side_conversation || cmd.available_in_side_conversation() {
return true;
}
@@ -921,8 +926,8 @@ impl ChatWidget {
false
}
fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool {
if cmd != SlashCommand::Side || !self.is_review_mode {
fn ensure_side_command_allowed_outside_review(&mut self, cmd: &SlashCommandAction) -> bool {
if cmd.as_builtin() != Some(SlashCommand::Side) || !self.is_review_mode {
return true;
}
@@ -6,4 +6,4 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" Fast on "
" Tier Fast "
+6 -14
View File
@@ -559,13 +559,7 @@ impl ChatWidget {
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
StatusLineItem::FastMode => Some(self.current_service_tier_status_label()),
StatusLineItem::ThreadTitle => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
@@ -679,13 +673,11 @@ 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_name()
.map(|name| format!(" {}", name.to_ascii_lowercase()))
.unwrap_or_default();
format!("{} {label}{service_tier_label}", self.model_display_name())
}
/// Computes the compact runtime status label used by word-based status items.
+7 -3
View File
@@ -372,8 +372,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": "priority",
"name": "Fast",
"description": "Fast tier",
})]
} else {
Vec::new()
};
@@ -387,7 +391,7 @@ 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,
"service_tiers": service_tiers,
"availability_nux": null,
"upgrade": null,
"base_instructions": "base instructions",
@@ -2170,7 +2170,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
description: "medium".to_string(),
}],
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
is_default: false,
upgrade: None,
show_in_picker,
@@ -2392,7 +2392,7 @@ async fn single_reasoning_option_skips_selection() {
default_reasoning_effort: ReasoningEffortConfig::High,
supported_reasoning_efforts: single_effort,
supports_personality: false,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
is_default: false,
upgrade: None,
show_in_picker: true,
@@ -1,4 +1,6 @@
use super::*;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use crate::bottom_pane::slash_commands::SlashCommandAction;
use pretty_assertions::assert_eq;
fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent {
@@ -23,6 +25,15 @@ fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn fast_service_tier_command() -> SlashCommandAction {
SlashCommandAction::ServiceTier(ServiceTierCommand {
service_tier: ServiceTier::priority(),
command: "fast".to_string(),
name: "Fast".to_string(),
description: "Fast tier".to_string(),
})
}
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
@@ -2050,14 +2061,14 @@ 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;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.dispatch_command(fast_service_tier_command());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(ServiceTier::priority())),
..
})
)),
@@ -2067,7 +2078,7 @@ 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(ServiceTier::priority()),
}
)),
"expected fast-mode persistence app event; events: {events:?}"
@@ -2083,7 +2094,7 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
set_chatgpt_auth(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.dispatch_command(fast_service_tier_command());
let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
@@ -2093,7 +2104,7 @@ 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(ServiceTier::priority())),
..
} => {}
other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"),
@@ -2129,7 +2140,7 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
service_tier: Some(Some(ServiceTier::priority())),
..
})
)),
@@ -2139,7 +2150,7 @@ 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(ServiceTier::priority())),
..
} => assert_eq!(
items,
@@ -2159,10 +2170,10 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
set_chatgpt_auth(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.dispatch_command(SlashCommand::Fast);
chat.dispatch_command(fast_service_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.dispatch_command_with_args(fast_service_tier_command(), "off".to_string(), Vec::new());
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
@@ -1037,14 +1037,14 @@ async fn commentary_completion_restores_status_indicator_before_exec_begin() {
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));
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.set_service_tier(Some(ServiceTier::priority()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
@@ -1053,11 +1053,17 @@ async fn fast_status_indicator_requires_chatgpt_auth() {
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));
assert!(
!get_available_model(&chat, "gpt-5.3-codex")
.supports_service_tier(&ServiceTier::priority())
);
chat.set_service_tier(Some(ServiceTier::priority()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
assert!(
!get_available_model(&chat, "gpt-5.3-codex")
.supports_service_tier(&ServiceTier::priority())
);
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
@@ -1066,10 +1072,10 @@ async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() {
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());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
@@ -1438,11 +1444,11 @@ async fn status_line_fast_mode_renders_on_and_off() {
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
assert_eq!(status_line_text(&chat), Some("Tier default".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast));
chat.set_service_tier(Some(ServiceTier::priority()));
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
assert_eq!(status_line_text(&chat), Some("Tier Fast".to_string()));
}
#[tokio::test]
@@ -1453,7 +1459,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::priority()));
chat.refresh_status_line();
let width = 80;
@@ -1472,7 +1478,7 @@ async fn status_line_fast_mode_footer_snapshot() {
async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models() {
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());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
@@ -1480,10 +1486,10 @@ 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::priority()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.refresh_status_line();
let test_cwd = test_path_display("/tmp/project");
@@ -1617,7 +1623,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() {
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());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
@@ -1626,10 +1632,10 @@ 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::priority()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.refresh_status_line();
let width = 80;
@@ -1651,7 +1657,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
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());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
@@ -1660,10 +1666,10 @@ 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::priority()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(get_available_model(&chat, "gpt-5.4").supports_service_tier(&ServiceTier::priority()));
chat.refresh_status_line();
let width = 80;
-6
View File
@@ -13,7 +13,6 @@ pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
Model,
Fast,
Approvals,
Permissions,
Keymap,
@@ -101,9 +100,6 @@ impl SlashCommand {
SlashCommand::MemoryDrop => "DO NOT USE",
SlashCommand::MemoryUpdate => "DO NOT USE",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Fast => {
"toggle Fast mode to enable fastest inference with increased plan usage"
}
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
@@ -145,7 +141,6 @@ impl SlashCommand {
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Goal
| SlashCommand::Fast
| SlashCommand::Mcp
| SlashCommand::Side
| SlashCommand::Resume
@@ -171,7 +166,6 @@ impl SlashCommand {
| SlashCommand::Compact
// | SlashCommand::Undo
| SlashCommand::Model
| SlashCommand::Fast
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
@@ -3343,9 +3343,16 @@ class ServerRequestResolvedNotification(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ServiceTier(Enum):
fast = "fast"
flex = "flex"
class ServiceTier(RootModel[str]):
model_config = ConfigDict(
populate_by_name=True,
)
root: Annotated[
str,
Field(
description="String-backed service tier identifier. Known values include `priority`, `flex`, and `ultrafast`, but other backend-provided ids are allowed."
),
]
class SessionSourceValue(Enum):
@@ -5854,13 +5861,19 @@ class MigrationDetails(BaseModel):
plugins: list[PluginsMigration]
class ModelServiceTier(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
description: str
id: ServiceTier
name: str
class Model(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
additional_speed_tiers: Annotated[
list[str] | None, Field(alias="additionalSpeedTiers")
] = []
availability_nux: Annotated[
ModelAvailabilityNux | None, Field(alias="availabilityNux")
] = None
@@ -5876,6 +5889,9 @@ class Model(BaseModel):
] = ["text", "image"]
is_default: Annotated[bool, Field(alias="isDefault")]
model: str
service_tiers: Annotated[
list[ModelServiceTier] | None, Field(alias="serviceTiers")
] = []
supported_reasoning_efforts: Annotated[
list[ReasoningEffortOption], Field(alias="supportedReasoningEfforts")
]