Merge 1915975876 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-12 06:51:31 -07:00
committed by GitHub
107 changed files with 6122 additions and 172 deletions
+16 -3
View File
@@ -1904,6 +1904,7 @@ dependencies = [
"codex-file-watcher",
"codex-git-attribution",
"codex-git-utils",
"codex-guardian",
"codex-hooks",
"codex-login",
"codex-mcp",
@@ -2907,6 +2908,7 @@ dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"codex-protocol",
"pretty_assertions",
]
@@ -2934,6 +2936,15 @@ dependencies = [
"walkdir",
]
[[package]]
name = "codex-guardian"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-protocol",
]
[[package]]
name = "codex-hooks"
version = "0.0.0"
@@ -3505,6 +3516,7 @@ dependencies = [
"anyhow",
"codex-code-mode",
"codex-protocol",
"http 1.4.0",
"pretty_assertions",
"serde",
"serde_json",
@@ -3793,6 +3805,7 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"sha2",
"shlex",
"strum 0.27.2",
"strum_macros 0.28.0",
@@ -8972,7 +8985,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -9440,7 +9453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -14084,7 +14097,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+2
View File
@@ -45,6 +45,7 @@ members = [
"execpolicy",
"execpolicy-legacy",
"ext/extension-api",
"ext/guardian",
"ext/git-attribution",
"external-agent-migration",
"external-agent-sessions",
@@ -162,6 +163,7 @@ codex-file-system = { path = "file-system" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-guardian = { path = "ext/guardian" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
@@ -12616,6 +12616,13 @@
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -9165,6 +9165,13 @@
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -452,6 +452,13 @@
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -506,6 +506,13 @@
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -408,6 +408,13 @@
"name": {
"type": "string"
},
"remotePluginId": {
"description": "Backend remote plugin identifier when available.",
"type": [
"string",
"null"
]
},
"shareContext": {
"anyOf": [
{
@@ -8,7 +8,11 @@ import type { PluginInterface } from "./PluginInterface";
import type { PluginShareContext } from "./PluginShareContext";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string,
export type PluginSummary = { id: string,
/**
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
@@ -538,6 +538,8 @@ pub enum PluginAvailability {
#[ts(export_to = "v2/")]
pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
pub name: String,
/// Remote sharing context associated with this plugin when available.
pub share_context: Option<PluginShareContext>,
@@ -3025,7 +3025,10 @@ fn plugin_share_list_response_serializes_share_items() {
serde_json::to_value(PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
id: "gmail@chatgpt-global".to_string(),
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
name: "gmail".to_string(),
share_context: None,
source: PluginSource::Remote,
@@ -3044,7 +3047,8 @@ fn plugin_share_list_response_serializes_share_items() {
json!({
"data": [{
"plugin": {
"id": "plugins~Plugin_00000000000000000000000000000000",
"id": "gmail@chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"shareContext": null,
"source": { "type": "remote" },
+1
View File
@@ -40,6 +40,7 @@ codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-guardian = { workspace = true }
codex-git-attribution = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
+31 -1
View File
@@ -1,11 +1,41 @@
use std::sync::Arc;
use std::sync::Weak;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_extension_api::AgentSpawnFuture;
use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
pub(crate) fn thread_extensions() -> Arc<ExtensionRegistry<Config>> {
pub(crate) fn thread_extensions<S>(guardian_agent_spawner: S) -> Arc<ExtensionRegistry<Config>>
where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
{
let mut builder = ExtensionRegistryBuilder::<Config>::new();
codex_git_attribution::install(&mut builder);
codex_guardian::install(&mut builder, guardian_agent_spawner);
Arc::new(builder.build())
}
pub(crate) fn guardian_agent_spawner(
thread_manager: Weak<ThreadManager>,
) -> impl AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> {
move |forked_from_thread_id: ThreadId,
options: StartThreadOptions|
-> AgentSpawnFuture<'static, NewThread, CodexErr> {
let thread_manager = thread_manager.clone();
Box::pin(async move {
let thread_manager = thread_manager.upgrade().ok_or_else(|| {
CodexErr::UnsupportedOperation("thread manager dropped".to_string())
})?;
thread_manager
.spawn_subagent(forked_from_thread_id, options)
.await
})
}
}
+15 -12
View File
@@ -99,6 +99,7 @@ async fn queue_refresh(
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use async_trait::async_trait;
use codex_arg0::Arg0DispatchPaths;
@@ -179,18 +180,20 @@ mod tests {
.await
.expect("refresh tests require state db");
let thread_store = thread_store_from_config(&good_config, Some(state_db.clone()));
let thread_manager = Arc::new(ThreadManager::new(
&good_config,
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(),
/*analytics_events_client*/ None,
thread_store,
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
));
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
&good_config,
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
/*analytics_events_client*/ None,
thread_store,
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
)
});
thread_manager.start_thread(good_config).await?;
thread_manager.start_thread(bad_config).await?;
+18 -15
View File
@@ -9,6 +9,7 @@ use crate::attestation::app_server_attestation_provider;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::invalid_request;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
@@ -299,21 +300,23 @@ impl MessageProcessor {
// affect per-thread behavior, but they must not move newly started,
// resumed, or forked threads to a different persistence backend/root.
let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone());
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
));
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
)
});
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
@@ -482,6 +482,7 @@ impl PluginRequestProcessor {
);
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
@@ -723,6 +724,7 @@ impl PluginRequestProcessor {
marketplace_path: outcome.marketplace_path,
summary: PluginSummary {
id: outcome.plugin.id,
remote_plugin_id: None,
name: outcome.plugin.name,
share_context,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
@@ -1107,7 +1109,6 @@ impl PluginRequestProcessor {
)
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
let remote_plugin_id = &remote_detail.summary.id;
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is disabled by admin"
)));
@@ -1587,6 +1588,7 @@ fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketpla
fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary {
PluginSummary {
id: summary.id,
remote_plugin_id: Some(summary.remote_plugin_id),
name: summary.name,
share_context: summary
.share_context
@@ -240,6 +240,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
interface: None,
plugins: vec![PluginSummary {
id: "valid-plugin@valid-marketplace".to_string(),
remote_plugin_id: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -529,6 +530,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
plugins: vec![
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
name: "valid-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -562,6 +564,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
},
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
remote_plugin_id: None,
name: "missing-plugin".to_string(),
share_context: None,
source: PluginSource::Local {
@@ -688,6 +691,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<(
.flat_map(|marketplace| marketplace.plugins.iter())
.find(|plugin| plugin.name == "demo-plugin")
.expect("expected demo-plugin entry");
assert_eq!(plugin.remote_plugin_id, None);
let share_context = plugin
.share_context
.as_ref()
@@ -1464,11 +1468,7 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
true,
true
)]
vec![("linear@chatgpt-global".to_string(), true, true)]
);
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
@@ -1629,9 +1629,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
Some("ChatGPT Plugins")
);
assert_eq!(remote_marketplace.plugins.len(), 1);
assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global");
assert_eq!(
remote_marketplace.plugins[0].id,
"plugins~Plugin_00000000000000000000000000000000"
remote_marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(remote_marketplace.plugins[0].name, "linear");
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
@@ -1725,12 +1726,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag
"plugins~Plugin_11111111111111111111111111111111",
"workspace-linear",
"Workspace Linear",
"LISTED",
/*enabled*/ None,
);
let workspace_installed_body = workspace_remote_plugin_page_body(
"plugins~Plugin_11111111111111111111111111111111",
"workspace-linear",
"Workspace Linear",
"LISTED",
/*enabled*/ Some(false),
);
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
@@ -1764,6 +1767,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag
Some("Workspace Directory")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(
marketplace.plugins[0].id,
"workspace-linear@workspace-directory"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_11111111111111111111111111111111")
);
assert_eq!(marketplace.plugins[0].name, "workspace-linear");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, false);
@@ -1803,6 +1814,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
"PRIVATE",
/*enabled*/ None,
))?;
shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null;
@@ -1811,6 +1823,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
"plugins~Plugin_22222222222222222222222222222222",
"shared-linear",
"Shared Linear",
"PRIVATE",
/*enabled*/ Some(true),
);
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
@@ -1844,6 +1857,11 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
Some("Shared with me")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me");
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_22222222222222222222222222222222")
);
assert_eq!(marketplace.plugins[0].name, "shared-linear");
assert_eq!(marketplace.plugins[0].installed, true);
assert_eq!(marketplace.plugins[0].enabled, true);
@@ -2381,6 +2399,7 @@ fn workspace_remote_plugin_page_body(
remote_plugin_id: &str,
plugin_name: &str,
display_name: &str,
discoverability: &str,
enabled: Option<bool>,
) -> String {
let enabled_field = enabled
@@ -2393,7 +2412,7 @@ fn workspace_remote_plugin_page_body(
"id": "{remote_plugin_id}",
"name": "{plugin_name}",
"scope": "WORKSPACE",
"discoverability": "PRIVATE",
"discoverability": "{discoverability}",
"creator_account_user_id": "user-gavin__account-123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"installation_policy": "AVAILABLE",
@@ -209,9 +209,10 @@ plugins = true
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
assert_eq!(
response.plugin.summary.id,
"plugins~Plugin_00000000000000000000000000000000"
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
@@ -313,6 +314,12 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "shared-with-me");
assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me");
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_11111111111111111111111111111111")
);
let share_context = response
.plugin
.summary
@@ -482,9 +489,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
assert_eq!(response.plugin.marketplace_path, None);
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
assert_eq!(
response.plugin.summary.id,
"plugins~Plugin_00000000000000000000000000000000"
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.installed, true);
@@ -856,6 +864,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<(
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
let share_context = response
.plugin
.summary
@@ -931,6 +940,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() ->
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.summary.remote_plugin_id, None);
let share_context = response
.plugin
.summary
@@ -162,7 +162,8 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -506,7 +507,8 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -725,7 +727,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins_123".to_string(),
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
@@ -617,11 +617,17 @@ async fn mount_remote_plugin_detail_with_name(
release_version: &str,
scope: &str,
) {
let discoverability = if scope == "WORKSPACE" {
r#"
"discoverability": "LISTED","#
} else {
""
};
let detail_body = format!(
r#"{{
"id": "{remote_plugin_id}",
"name": "{plugin_name}",
"scope": "{scope}",
"scope": "{scope}",{discoverability}
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {{
+22
View File
@@ -599,6 +599,16 @@ impl fmt::Display for NotificationCondition {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TuiPetAnchor {
/// Anchor the pet to the bottom of the current TUI composer viewport.
#[default]
Composer,
/// Anchor the pet to the physical bottom of the terminal screen.
ScreenBottom,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiNotificationSettings {
@@ -695,6 +705,18 @@ pub struct Tui {
#[serde(default)]
pub theme: Option<String>,
/// Pet id to preselect in the terminal pet picker.
///
/// Custom pet ids resolve against CODEX_HOME/pets/<pet-id>/pet.json.
#[serde(default)]
pub pet: Option<String>,
/// Where the terminal pet should anchor vertically.
///
/// Defaults to `composer`, which follows the current TUI composer viewport.
#[serde(default)]
pub pet_anchor: TuiPetAnchor,
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
+1
View File
@@ -22,6 +22,7 @@ pub use codex_config::types::SessionPickerViewMode;
pub use codex_config::types::ToolSuggestConfig;
pub use codex_config::types::TuiKeymap;
pub use codex_config::types::TuiNotificationSettings;
pub use codex_config::types::TuiPetAnchor;
pub use codex_config::types::UriBasedFileOpener;
pub use codex_core::CodexThread;
pub use codex_core::ForkSnapshot;
+47 -20
View File
@@ -87,6 +87,7 @@ pub struct RemoteInstalledPlugin {
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSummary {
pub id: String,
pub remote_plugin_id: String,
pub name: String,
pub share_context: Option<RemotePluginShareContext>,
pub installed: bool,
@@ -379,6 +380,32 @@ struct RemotePluginDirectoryItem {
release: RemotePluginReleaseResponse,
}
fn remote_plugin_canonical_marketplace_name(
plugin: &RemotePluginDirectoryItem,
) -> Result<&'static str, RemotePluginCatalogError> {
match plugin.scope {
RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME),
RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? {
RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME),
RemotePluginShareDiscoverability::Unlisted
| RemotePluginShareDiscoverability::Private => {
Ok(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME)
}
},
}
}
fn workspace_plugin_discoverability(
plugin: &RemotePluginDirectoryItem,
) -> Result<RemotePluginShareDiscoverability, RemotePluginCatalogError> {
plugin.discoverability.ok_or_else(|| {
RemotePluginCatalogError::UnexpectedResponse(format!(
"workspace plugin `{}` did not include discoverability",
plugin.id
))
})
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginDirectorySharePrincipal {
principal_type: RemotePluginSharePrincipalType,
@@ -550,12 +577,9 @@ pub async fn fetch_remote_installed_plugins(
let (global, workspace) = tokio::try_join!(global, workspace)?;
let mut installed_plugins = [global, workspace]
.into_iter()
.flat_map(|(scope, plugins)| {
plugins
.into_iter()
.map(move |plugin| remote_installed_plugin_to_info(scope, &plugin))
})
.collect::<Vec<_>>();
.flat_map(|(_scope, plugins)| plugins)
.map(|plugin| remote_installed_plugin_to_info(&plugin))
.collect::<Result<Vec<_>, _>>()?;
installed_plugins.sort_by(|left, right| {
left.marketplace_name
.cmp(&right.marketplace_name)
@@ -655,7 +679,7 @@ async fn fetch_remote_plugin_detail_with_download_url_option(
let auth = ensure_chatgpt_auth(auth)?;
let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?;
let scope = plugin.scope;
let marketplace_name = scope.marketplace_name().to_string();
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
// marketplace name is not validated here. The backend detail response is the
// source of truth for the plugin's actual scope/marketplace.
@@ -756,7 +780,7 @@ pub async fn uninstall_remote_plugin(
config, auth, plugin_id, /*include_download_urls*/ false,
)
.await?;
let marketplace_name = plugin.scope.marketplace_name().to_string();
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
let plugin_name = plugin.name;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
@@ -841,8 +865,17 @@ fn build_remote_plugin_summary(
plugin: &RemotePluginDirectoryItem,
installed_plugin: Option<&RemotePluginInstalledItem>,
) -> Result<RemotePluginSummary, RemotePluginCatalogError> {
let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?;
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
RemotePluginCatalogError::UnexpectedResponse(format!(
"invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}",
plugin.name
))
})?;
Ok(RemotePluginSummary {
id: plugin.id.clone(),
id: plugin_id.as_key(),
remote_plugin_id: plugin.id.clone(),
name: plugin.name.clone(),
share_context: remote_plugin_share_context(plugin)?,
installed: installed_plugin.is_some(),
@@ -861,12 +894,7 @@ fn remote_plugin_share_context(
match plugin.scope {
RemotePluginScope::Global => Ok(None),
RemotePluginScope::Workspace => {
let discoverability = plugin.discoverability.ok_or_else(|| {
RemotePluginCatalogError::UnexpectedResponse(format!(
"workspace plugin `{}` did not include discoverability",
plugin.id
))
})?;
let discoverability = workspace_plugin_discoverability(plugin)?;
Ok(Some(RemotePluginShareContext {
remote_plugin_id: plugin.id.clone(),
discoverability,
@@ -890,19 +918,18 @@ fn remote_plugin_share_context(
}
fn remote_installed_plugin_to_info(
scope: RemotePluginScope,
installed_plugin: &RemotePluginInstalledItem,
) -> RemoteInstalledPlugin {
) -> Result<RemoteInstalledPlugin, RemotePluginCatalogError> {
let plugin = &installed_plugin.plugin;
// Remote per-skill disabled state (`disabled_skill_names`) is intentionally
// not projected into skills/list yet; local skills.config remains the
// supported source for skill enablement.
RemoteInstalledPlugin {
marketplace_name: scope.marketplace_name().to_string(),
Ok(RemoteInstalledPlugin {
marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(),
id: plugin.id.clone(),
name: plugin.name.clone(),
enabled: installed_plugin.enabled,
}
})
}
fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option<PluginInterface> {
@@ -1,10 +1,12 @@
use super::REMOTE_GLOBAL_MARKETPLACE_NAME;
use super::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME;
use super::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use super::RemotePluginCatalogError;
use super::RemotePluginScope;
use super::RemotePluginServiceConfig;
use super::ensure_chatgpt_auth;
use super::fetch_installed_plugins_for_scope_with_download_url;
use super::remote_plugin_canonical_marketplace_name;
use crate::store::PLUGINS_CACHE_DIR;
use crate::store::PluginStore;
use crate::store::PluginStoreError;
@@ -150,14 +152,18 @@ pub async fn sync_remote_installed_plugin_bundles_once(
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
]);
let mut installed_plugin_ids = BTreeSet::new();
let mut failed_remote_plugin_ids = BTreeSet::new();
for (scope, installed_plugins) in [global, workspace] {
let marketplace_name = scope.marketplace_name().to_string();
for (_scope, installed_plugins) in [global, workspace] {
for installed_plugin in installed_plugins {
let plugin = installed_plugin.plugin;
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
installed_plugin_names_by_marketplace
.entry(marketplace_name.clone())
.or_default()
@@ -292,6 +298,7 @@ fn remove_stale_remote_plugin_caches(
for marketplace_name in [
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME,
] {
let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name);
if !marketplace_root.exists() {
@@ -449,6 +456,10 @@ mod tests {
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
]);
let guard = mark_remote_plugin_cache_mutation_in_flight(
@@ -487,4 +498,42 @@ mod tests {
assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]);
assert!(!cached_manifest.exists());
}
#[test]
fn stale_remote_plugin_cleanup_removes_shared_with_me_cache() {
let codex_home = tempfile::tempdir().expect("create codex home");
let cached_manifest = codex_home
.path()
.join(PLUGINS_CACHE_DIR)
.join(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME)
.join("private-plugin")
.join("1.2.3")
.join(".codex-plugin")
.join("plugin.json");
std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent"))
.expect("create cached plugin manifest parent");
std::fs::write(&cached_manifest, r#"{"name":"private-plugin"}"#)
.expect("write cached plugin manifest");
let installed_plugin_names_by_marketplace =
BTreeMap::<String, BTreeSet<String>>::from_iter([
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
(
REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(),
BTreeSet::new(),
),
]);
let removed = remove_stale_remote_plugin_caches(
codex_home.path(),
&installed_plugin_names_by_marketplace,
)
.expect("cleanup shared-with-me cache");
assert_eq!(removed, vec!["private-plugin@shared-with-me".to_string()]);
assert!(!cached_manifest.exists());
}
}
@@ -93,6 +93,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"discoverability": "PRIVATE",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
@@ -584,7 +585,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
vec![
RemotePluginShareSummary {
summary: RemotePluginSummary {
id: "plugins_123".to_string(),
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_123".to_string(),
@@ -621,7 +623,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
},
RemotePluginShareSummary {
summary: RemotePluginSummary {
id: "plugins_456".to_string(),
id: "demo-plugin@shared-with-me".to_string(),
remote_plugin_id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
share_context: Some(RemotePluginShareContext {
remote_plugin_id: "plugins_456".to_string(),
+32
View File
@@ -2692,6 +2692,20 @@
"default": true,
"description": "Enable desktop notifications from the TUI. Defaults to `true`."
},
"pet": {
"default": null,
"description": "Pet id to preselect in the terminal pet picker.\n\nCustom pet ids resolve against CODEX_HOME/pets/<pet-id>/pet.json.",
"type": "string"
},
"pet_anchor": {
"allOf": [
{
"$ref": "#/definitions/TuiPetAnchor"
}
],
"default": "composer",
"description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport."
},
"raw_output_mode": {
"default": false,
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
@@ -3436,6 +3450,24 @@
},
"type": "object"
},
"TuiPetAnchor": {
"oneOf": [
{
"description": "Anchor the pet to the bottom of the current TUI composer viewport.",
"enum": [
"composer"
],
"type": "string"
},
{
"description": "Anchor the pet to the physical bottom of the terminal screen.",
"enum": [
"screen-bottom"
],
"type": "string"
}
]
},
"TuiVimNormalKeymap": {
"additionalProperties": false,
"description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.",
+2 -1
View File
@@ -1256,7 +1256,7 @@ impl ModelClientSession {
self.client.state.auth_env_telemetry.clone(),
);
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self
let mut options = self
.build_responses_options(turn_metadata_header, compression)
.await;
@@ -1269,6 +1269,7 @@ impl ModelClientSession {
service_tier.clone(),
)?;
let inference_trace_attempt = inference_trace.start_attempt();
inference_trace_attempt.add_request_headers(&mut options.extra_headers);
inference_trace_attempt.record_started(&request);
let client = ApiResponsesClient::new(
transport,
+76
View File
@@ -55,6 +55,7 @@ use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::types::Tui;
use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::TuiPetAnchor;
use codex_config::types::WindowsSandboxModeToml;
use codex_config::types::WindowsToml;
use codex_core_plugins::PluginsManager;
@@ -573,6 +574,8 @@ fn config_toml_deserializes_model_availability_nux() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
@@ -2530,6 +2533,19 @@ session_picker_view = "dense"
);
}
#[test]
fn tui_pet_deserializes_from_toml() {
let cfg = r#"
[tui]
pet = "chefito"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.pet.as_deref()),
Some("chefito"),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
@@ -2542,6 +2558,56 @@ fn tui_session_picker_view_defaults_to_none() {
);
}
#[test]
fn tui_pet_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), None);
}
#[test]
fn tui_pet_anchor_deserializes_from_toml() {
let cfg = r#"
[tui]
pet_anchor = "screen-bottom"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().map(|t| t.pet_anchor),
Some(TuiPetAnchor::ScreenBottom),
);
}
#[test]
fn tui_pet_anchor_defaults_to_composer() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().map(|t| t.pet_anchor),
Some(TuiPetAnchor::Composer),
);
}
#[test]
fn tui_pet_anchor_rejects_unknown_value() {
let cfg = r#"
[tui]
pet_anchor = "bottom"
"#;
let err = toml::from_str::<ConfigToml>(cfg).expect_err("reject unknown pet anchor");
let err = err.to_string();
assert!(
err.contains("unknown variant `bottom`")
&& err.contains("composer")
&& err.contains("screen-bottom"),
"unexpected error: {err}"
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
@@ -2565,6 +2631,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
status_line_use_colors: true,
terminal_title: None,
theme: None,
pet: None,
pet_anchor: TuiPetAnchor::Composer,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
@@ -7338,6 +7406,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
},
@@ -7783,6 +7853,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -7942,6 +8014,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -8086,6 +8160,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
+8
View File
@@ -92,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
}
}
/// Produces a config edit that sets [tui].pet = "<name>".
pub fn tui_pet_edit(name: &str) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "pet".to_string()],
value: value(name.to_string()),
}
}
/// Produces a config edit that sets `[tui].session_picker_view = "<mode>"`.
pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit {
ConfigEdit::SetPath {
+13
View File
@@ -51,6 +51,7 @@ use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::TuiPetAnchor;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_core_plugins::PluginsConfigInput;
@@ -616,6 +617,12 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
/// Pet id preselected by the terminal pet picker.
pub tui_pet: Option<String>,
/// Vertical anchor used by terminal pet rendering.
pub tui_pet_anchor: TuiPetAnchor,
/// Preferred layout for resume/fork session picker results.
pub tui_session_picker_view: SessionPickerViewMode,
@@ -3267,6 +3274,12 @@ impl Config {
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_pet: cfg.tui.as_ref().and_then(|t| t.pet.clone()),
tui_pet_anchor: cfg
.tui
.as_ref()
.map(|t| t.pet_anchor)
.unwrap_or_default(),
tui_session_picker_view: config_profile
.tui
.as_ref()
+1
View File
@@ -855,6 +855,7 @@ impl Session {
let thread_extension_data = codex_extension_api::ExtensionData::new();
for contributor in extensions.thread_start_contributors() {
contributor.contribute(
thread_id,
config.as_ref(),
&session_extension_data,
&thread_extension_data,
+30
View File
@@ -567,6 +567,36 @@ impl ThreadManager {
.await
}
// TODO(jif) merge with fork_agent
/// Spawn a subagent by forking persisted history from `forked_from_thread_id`.
pub async fn spawn_subagent(
&self,
forked_from_thread_id: ThreadId,
mut options: StartThreadOptions,
) -> CodexResult<NewThread> {
let fork_source = self.get_thread(forked_from_thread_id).await?;
// Persist queued rollout updates before reading the fork snapshot.
fork_source.ensure_rollout_materialized().await;
fork_source.flush_rollout().await?;
let stored_thread = fork_source
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
)
.await
.map_err(|err| {
CodexErr::Fatal(format!(
"failed to read subagent fork source {forked_from_thread_id}: {err}"
))
})?;
let history = stored_thread_to_initial_history(stored_thread, fork_source.rollout_path())?;
options.initial_history = fork_history_from_snapshot(
ForkSnapshot::Interrupted,
history,
InterruptedTurnHistoryMarker::from_config(&options.config),
);
self.start_thread_with_options(options).await
}
pub async fn resume_thread_from_rollout(
&self,
config: Config,
@@ -0,0 +1,38 @@
use std::future::Future;
use std::pin::Pin;
use codex_protocol::ThreadId;
/// Future returned by one injected subagent-spawn helper.
pub type AgentSpawnFuture<'a, T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
/// Constructor-injected host helper for extensions that need to spawn subagents.
///
/// The extension owns the request shape and resulting handle types. The host
/// provides the implementation when it constructs the extension.
pub trait AgentSpawner<R>: Send + Sync {
type Spawned;
type Error;
fn spawn_subagent<'a>(
&'a self,
forked_from_thread_id: ThreadId,
request: R,
) -> AgentSpawnFuture<'a, Self::Spawned, Self::Error>;
}
impl<R, S, E, F> AgentSpawner<R> for F
where
F: Fn(ThreadId, R) -> AgentSpawnFuture<'static, S, E> + Send + Sync,
{
type Spawned = S;
type Error = E;
fn spawn_subagent<'a>(
&'a self,
forked_from_thread_id: ThreadId,
request: R,
) -> AgentSpawnFuture<'a, Self::Spawned, Self::Error> {
self(forked_from_thread_id, request)
}
}
@@ -0,0 +1,4 @@
mod agent;
pub use agent::AgentSpawnFuture;
pub use agent::AgentSpawner;
+10 -3
View File
@@ -1,5 +1,6 @@
use std::future::Future;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem;
use codex_tool_api::ToolBundle;
@@ -10,10 +11,16 @@ mod prompt;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
/// Contributor that receives host-owned thread-start input before later
/// contributors read from extension stores.
/// Contributor that receives the live thread id and host-owned thread-start
/// input before later contributors read from extension stores.
pub trait ThreadStartContributor<C>: Send + Sync {
fn contribute(&self, input: &C, session_store: &ExtensionData, thread_store: &ExtensionData);
fn contribute(
&self,
thread_id: ThreadId,
input: &C,
session_store: &ExtensionData,
thread_store: &ExtensionData,
);
}
/// Extension contribution that adds prompt fragments during prompt assembly.
+3
View File
@@ -1,7 +1,10 @@
mod capabilities;
mod contributors;
mod registry;
mod state;
pub use capabilities::AgentSpawnFuture;
pub use capabilities::AgentSpawner;
pub use codex_tool_api::FunctionToolSpec;
pub use codex_tool_api::ToolBundle;
pub use codex_tool_api::ToolCall;
+1
View File
@@ -16,6 +16,7 @@ workspace = true
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+2
View File
@@ -7,6 +7,7 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadStartContributor;
use codex_features::Feature;
use codex_protocol::ThreadId;
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
@@ -42,6 +43,7 @@ struct GitAttributionConfig {
impl ThreadStartContributor<Config> for GitAttributionExtension {
fn contribute(
&self,
_thread_id: ThreadId,
config: &Config,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "guardian",
crate_name = "codex_guardian",
)
+19
View File
@@ -0,0 +1,19 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-guardian"
version.workspace = true
[lib]
name = "codex_guardian"
path = "src/lib.rs"
test = false
doctest = false
[lints]
workspace = true
[dependencies]
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-protocol = { workspace = true }
+73
View File
@@ -0,0 +1,73 @@
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::AgentSpawnFuture;
use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadStartContributor;
use codex_protocol::ThreadId;
/// Guardian extension dependencies supplied by the host at construction time.
#[derive(Clone, Debug)]
pub struct GuardianExtension<S> {
agent_spawner: S,
}
impl<S> GuardianExtension<S> {
/// Creates a guardian extension with its host-provided agent spawn helper.
pub fn new(agent_spawner: S) -> Self {
Self { agent_spawner }
}
/// Delegates one guardian-owned subagent spawn request to the host helper.
pub fn spawn_subagent<'a, R>(
&'a self,
forked_from_thread_id: ThreadId,
request: R,
) -> AgentSpawnFuture<'a, <S as AgentSpawner<R>>::Spawned, <S as AgentSpawner<R>>::Error>
where
S: AgentSpawner<R>,
{
self.agent_spawner
.spawn_subagent(forked_from_thread_id, request)
}
}
/// Thread-local guardian state captured when the host starts a thread.
#[derive(Clone, Copy, Debug)]
pub struct GuardianThreadContext {
forked_from_thread_id: ThreadId,
}
impl GuardianThreadContext {
/// Returns the thread that future guardian subagents should fork from by default.
pub fn forked_from_thread_id(&self) -> ThreadId {
self.forked_from_thread_id
}
}
impl<S> ThreadStartContributor<Config> for GuardianExtension<S>
where
S: Send + Sync,
{
fn contribute(
&self,
thread_id: ThreadId,
_input: &Config,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) {
thread_store.insert(GuardianThreadContext {
forked_from_thread_id: thread_id,
});
}
}
/// Installs the guardian contributors into the extension registry.
pub fn install<S>(registry: &mut ExtensionRegistryBuilder<Config>, agent_spawner: S)
where
S: Send + Sync + 'static,
{
registry.thread_start_contributor(Arc::new(GuardianExtension::new(agent_spawner)));
}
+2 -1
View File
@@ -16,10 +16,11 @@ workspace = true
anyhow = { workspace = true }
codex-code-mode = { workspace = true }
codex-protocol = { workspace = true }
http = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
+67 -4
View File
@@ -7,13 +7,15 @@
use std::fmt::Display;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TokenUsage;
use http::HeaderMap;
use http::HeaderValue;
use serde::Serialize;
use serde_json::Value as JsonValue;
use uuid::Uuid;
use crate::model::AgentThreadId;
use crate::model::CodexTurnId;
@@ -23,7 +25,7 @@ use crate::raw_event::RawTraceEventContext;
use crate::raw_event::RawTraceEventPayload;
use crate::writer::TraceWriter;
static NEXT_INFERENCE_ATTEMPT: AtomicU64 = AtomicU64::new(1);
const INFERENCE_CALL_ID_HEADER: &str = "x-codex-inference-call-id";
/// Turn-local inference tracing context.
///
@@ -140,6 +142,30 @@ impl InferenceTraceAttempt {
}
}
fn inference_call_id(&self) -> Option<&str> {
match &self.state {
InferenceTraceAttemptState::Disabled => None,
InferenceTraceAttemptState::Enabled(attempt) => {
Some(attempt.inference_call_id.as_str())
}
}
}
/// Adds rollout-trace propagation headers for this attempt when tracing is enabled.
pub fn add_request_headers(&self, headers: &mut HeaderMap) {
let Some(inference_call_id) = self.inference_call_id() else {
return;
};
let Ok(inference_call_id) = HeaderValue::from_str(inference_call_id) else {
// These IDs are generated internally as UUID strings, so rejection
// should be impossible in practice. Tracing remains best-effort,
// though, and must never make provider requests fail.
return;
};
headers.insert(INFERENCE_CALL_ID_HEADER, inference_call_id);
}
/// Records the exact request object about to be sent to the model provider.
pub fn record_started(&self, request: &impl Serialize) {
let InferenceTraceAttemptState::Enabled(attempt) = &self.state else {
@@ -315,8 +341,7 @@ pub(crate) fn trace_response_item_json(item: &ResponseItem) -> JsonValue {
}
fn next_inference_call_id() -> InferenceCallId {
let ordinal = NEXT_INFERENCE_ATTEMPT.fetch_add(1, Ordering::Relaxed);
format!("inference:{ordinal}")
Uuid::new_v4().to_string()
}
fn write_json_payload_best_effort(
@@ -372,6 +397,44 @@ mod tests {
use crate::model::ExecutionStatus;
use crate::replay_bundle;
#[test]
fn disabled_attempt_adds_no_request_headers() {
let mut headers = HeaderMap::new();
InferenceTraceAttempt::disabled().add_request_headers(&mut headers);
assert!(headers.is_empty());
}
#[test]
fn enabled_attempt_adds_inference_request_header() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let writer = Arc::new(TraceWriter::create(
temp.path(),
"trace-1".to_string(),
"rollout-1".to_string(),
"thread-root".to_string(),
)?);
let context = InferenceTraceContext::enabled(
writer,
"thread-root".to_string(),
"turn-1".to_string(),
"gpt-test".to_string(),
"test-provider".to_string(),
);
let attempt = context.start_attempt();
let mut headers = HeaderMap::new();
attempt.add_request_headers(&mut headers);
let header = headers
.get(INFERENCE_CALL_ID_HEADER)
.expect("inference header present");
assert_eq!(Some(header.to_str()?), attempt.inference_call_id());
assert!(Uuid::parse_str(header.to_str()?).is_ok());
Ok(())
}
#[test]
fn enabled_context_records_replayable_inference_attempt() -> anyhow::Result<()> {
let temp = TempDir::new()?;
@@ -47,6 +47,7 @@ use codex_core_api::ThreadStoreConfig;
use codex_core_api::ToolSuggestConfig;
use codex_core_api::TuiKeymap;
use codex_core_api::TuiNotificationSettings;
use codex_core_api::TuiPetAnchor;
use codex_core_api::UriBasedFileOpener;
use codex_core_api::UserInput;
use codex_core_api::WebSearchMode;
@@ -198,6 +199,8 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tui_terminal_title: None,
tui_theme: None,
tui_raw_output_mode: false,
tui_pet: None,
tui_pet_anchor: TuiPetAnchor::Composer,
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
tui_keymap: TuiKeymap::default(),
tui_session_picker_view: SessionPickerViewMode::Dense,
+2 -1
View File
@@ -86,10 +86,11 @@ ratatui = { workspace = true, features = [
] }
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
reqwest = { workspace = true, features = ["blocking", "json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
sha2 = { workspace = true }
shlex = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
+34
View File
@@ -155,6 +155,7 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::backend::Backend;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -192,6 +193,7 @@ mod history_ui;
mod input;
mod loaded_threads;
mod pending_interactive_replay;
mod pets;
mod platform_actions;
mod replay_filter;
mod resize_reflow;
@@ -1080,13 +1082,18 @@ See the Codex keymap documentation for supported actions and examples."
if let Err(err) = app_server.shutdown().await {
tracing::warn!(error = %err, "failed to shut down embedded app server");
}
let clear_pet_result = tui.clear_ambient_pet_image();
let clear_result = tui.terminal.clear();
let exit_reason = match exit_reason_result {
Ok(exit_reason) => {
clear_pet_result?;
clear_result?;
exit_reason
}
Err(err) => {
if let Err(clear_pet_err) = clear_pet_result {
tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image");
}
if let Err(clear_err) = clear_result {
tracing::warn!(error = %clear_err, "failed to clear terminal UI");
}
@@ -1154,9 +1161,11 @@ See the Codex keymap documentation for supported actions and examples."
self.chat_widget.pre_draw_tick();
let desired_height =
self.chat_widget.desired_height(tui.terminal.size()?.width);
let mut rendered_area = Rect::default();
if terminal_resize_reflow_enabled {
tui.draw_with_resize_reflow(desired_height, |frame| {
let area = frame.area();
rendered_area = area;
self.chat_widget.render(area, frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(area) {
frame.set_cursor_style(self.chat_widget.cursor_style(area));
@@ -1166,6 +1175,7 @@ See the Codex keymap documentation for supported actions and examples."
} else {
tui.draw(desired_height, |frame| {
let area = frame.area();
rendered_area = area;
self.chat_widget.render(area, frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(area) {
frame.set_cursor_style(self.chat_widget.cursor_style(area));
@@ -1173,6 +1183,30 @@ See the Codex keymap documentation for supported actions and examples."
}
})?;
}
if self.chat_widget.ambient_pet_image_enabled() {
let terminal_size = tui.terminal.size()?;
let ambient_pet_area = Rect::new(
/*x*/ 0,
/*y*/ 0,
terminal_size.width,
terminal_size.height,
);
if let Err(err) = tui.draw_ambient_pet_image(
self.chat_widget
.ambient_pet_draw(ambient_pet_area, rendered_area.bottom()),
) {
self.handle_ambient_pet_image_render_error(tui, err)?;
}
}
if let Some(request) = self.chat_widget.pet_picker_preview_draw() {
if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) {
self.handle_pet_picker_preview_image_render_error(tui, err)?;
}
} else if self.chat_widget.should_clear_pet_picker_preview_image()
&& let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None)
{
self.handle_pet_picker_preview_image_render_error(tui, err)?;
}
if self.chat_widget.external_editor_state() == ExternalEditorState::Requested {
self.chat_widget
.set_external_editor_state(ExternalEditorState::Active);
@@ -515,6 +515,18 @@ impl App {
self.chat_widget.set_tui_theme(Some(name));
}
#[cfg(test)]
pub(super) fn sync_tui_pet_selection(&mut self, pet: String) {
self.config.tui_pet = Some(pet.clone());
self.chat_widget.set_tui_pet(Some(pet));
}
pub(super) fn sync_tui_pet_disabled(&mut self) {
let pet = crate::pets::DISABLED_PET_ID.to_string();
self.config.tui_pet = Some(pet.clone());
self.chat_widget.set_tui_pet(Some(pet));
}
pub(super) fn restore_runtime_theme_from_config(&self) {
if let Some(name) = self.config.tui_theme.as_deref()
&& let Some(theme) =
@@ -732,4 +744,33 @@ terminal_resize_reflow_max_rows = 9000
Some("dracula")
);
}
#[tokio::test]
async fn sync_tui_pet_selection_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
app.sync_tui_pet_selection("chefito".to_string());
assert_eq!(app.config.tui_pet.as_deref(), Some("chefito"));
assert_eq!(
app.chat_widget.config_ref().tui_pet.as_deref(),
Some("chefito")
);
}
#[tokio::test]
async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() {
let mut app = make_test_app().await;
app.sync_tui_pet_disabled();
assert_eq!(
app.config.tui_pet.as_deref(),
Some(crate::pets::DISABLED_PET_ID)
);
assert_eq!(
app.chat_widget.config_ref().tui_pet.as_deref(),
Some(crate::pets::DISABLED_PET_ID)
);
}
}
+30 -3
View File
@@ -204,13 +204,15 @@ impl App {
self.insert_history_cell_lines_with_initial_replay_buffer(
tui,
cell.as_ref(),
tui.terminal.last_known_screen_size.width,
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
} else {
self.insert_history_cell_lines(
tui,
cell.as_ref(),
tui.terminal.last_known_screen_size.width,
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
}
}
@@ -262,7 +264,8 @@ impl App {
self.insert_history_cell_lines(
tui,
consolidated.as_ref(),
tui.terminal.last_known_screen_size.width,
self.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width),
);
self.maybe_finish_stream_reflow(tui)?;
@@ -389,6 +392,30 @@ impl App {
AppEvent::OpenUrlInBrowser { url } => {
self.open_url_in_browser(url);
}
AppEvent::PetSelected { pet_id } => {
self.handle_pet_selected(tui, pet_id);
}
AppEvent::PetDisabled => {
self.handle_pet_disabled(tui).await;
}
AppEvent::PetPreviewRequested { pet_id } => {
self.chat_widget.start_pet_picker_preview(pet_id);
}
AppEvent::PetPreviewLoaded { request_id, result } => {
self.handle_pet_preview_loaded(tui, request_id, result);
}
AppEvent::PetSelectionLoaded {
request_id,
pet_id,
result,
} => {
return self
.handle_pet_selection_loaded(tui, request_id, pet_id, result)
.await;
}
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
self.handle_configured_pet_loaded(tui, pet_id, result);
}
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
+3 -1
View File
@@ -41,7 +41,9 @@ impl App {
}
pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) {
let width = tui.terminal.last_known_screen_size.width;
let width = self
.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width);
let header_lines = self.clear_ui_header_lines(width);
if !header_lines.is_empty() {
tui.insert_history_lines(header_lines);
+181
View File
@@ -0,0 +1,181 @@
//! App-level handlers for ambient terminal pet events.
use super::*;
impl App {
pub(super) fn handle_ambient_pet_image_render_error(
&mut self,
tui: &mut tui::Tui,
err: crate::pets::PetImageRenderError,
) -> Result<()> {
match err {
crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()),
crate::pets::PetImageRenderError::Asset(err) => {
tracing::warn!(
error = %err,
"failed to render ambient pet image; disabling pet for session"
);
self.chat_widget.disable_ambient_pet_for_session();
if let Err(clear_err) = tui.clear_ambient_pet_image() {
match clear_err {
crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()),
crate::pets::PetImageRenderError::Asset(err) => {
tracing::warn!(
error = %err,
"failed to clear ambient pet image after render failure"
);
}
}
}
Ok(())
}
}
}
pub(super) fn handle_pet_picker_preview_image_render_error(
&mut self,
tui: &mut tui::Tui,
err: crate::pets::PetImageRenderError,
) -> Result<()> {
match err {
crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()),
crate::pets::PetImageRenderError::Asset(err) => {
tracing::warn!(error = %err, "failed to render pet picker preview image");
self.chat_widget
.fail_pet_picker_preview_render(err.to_string());
if let Err(clear_err) = tui.draw_pet_picker_preview_image(/*request*/ None) {
match clear_err {
crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()),
crate::pets::PetImageRenderError::Asset(err) => {
tracing::warn!(
error = %err,
"failed to clear pet picker preview image after render failure"
);
}
}
}
Ok(())
}
}
}
pub(super) fn handle_pet_selected(&mut self, tui: &mut tui::Tui, pet_id: String) {
let request_id = self.chat_widget.show_pet_selection_loading_popup();
tui.frame_requester().schedule_frame();
let codex_home = self.config.codex_home.clone();
let frame_requester = tui.frame_requester();
let animations_enabled = self.config.animations;
let tx = self.app_event_tx.clone();
std::mem::drop(tokio::task::spawn_blocking(move || {
let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home)
.and_then(|()| {
crate::pets::AmbientPet::load(
Some(&pet_id),
&codex_home,
frame_requester,
animations_enabled,
)
})
.map(Some)
.map_err(|err| err.to_string());
tx.send(AppEvent::PetSelectionLoaded {
request_id,
pet_id,
result,
});
}));
}
pub(super) async fn handle_pet_disabled(&mut self, tui: &mut tui::Tui) {
let edit = crate::legacy_core::config::edit::tui_pet_edit(crate::pets::DISABLED_PET_ID);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.sync_tui_pet_disabled();
tui.frame_requester().schedule_frame();
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to disable pets: {err}"));
}
}
}
pub(super) fn handle_pet_preview_loaded(
&mut self,
tui: &mut tui::Tui,
request_id: u64,
result: Result<crate::pets::AmbientPet, String>,
) {
self.chat_widget
.finish_pet_picker_preview_load(request_id, result);
tui.frame_requester().schedule_frame();
}
pub(super) async fn handle_pet_selection_loaded(
&mut self,
tui: &mut tui::Tui,
request_id: u64,
pet_id: String,
result: Result<Option<crate::pets::AmbientPet>, String>,
) -> Result<AppRunControl> {
if !self
.chat_widget
.finish_pet_selection_loading_popup(request_id)
{
return Ok(AppRunControl::Continue);
}
match result {
Ok(ambient_pet) => {
let edit = crate::legacy_core::config::edit::tui_pet_edit(&pet_id);
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await
{
Ok(()) => {
self.config.tui_pet = Some(pet_id.clone());
self.chat_widget
.set_tui_pet_loaded(Some(pet_id), ambient_pet);
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to save pet selection: {err}"));
}
}
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to load pet: {err}"));
}
}
tui.frame_requester().schedule_frame();
Ok(AppRunControl::Continue)
}
pub(super) fn handle_configured_pet_loaded(
&mut self,
tui: &mut tui::Tui,
pet_id: String,
result: Result<Option<crate::pets::AmbientPet>, String>,
) {
if self.config.tui_pet.as_deref() != Some(pet_id.as_str()) {
return;
}
match result {
Ok(ambient_pet) => {
self.chat_widget
.set_tui_pet_loaded(Some(pet_id), ambient_pet);
tui.frame_requester().schedule_frame();
}
Err(err) => {
self.chat_widget
.add_warning_message(format!("Failed to load configured pet: {err}"));
}
}
}
}
+4 -3
View File
@@ -419,12 +419,13 @@ impl App {
}
pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result<u16> {
let width = tui.terminal.size()?.width;
let terminal_width = tui.terminal.size()?.width;
let width = self.chat_widget.history_wrap_width(terminal_width);
if self.transcript_cells.is_empty() {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
self.reset_history_emission_state();
return Ok(width);
return Ok(terminal_width);
}
let reflow_result = self.render_transcript_lines_for_reflow(width);
@@ -442,7 +443,7 @@ impl App {
);
}
Ok(width)
Ok(terminal_width)
}
/// Render transcript cells for the current resize rebuild.
+28
View File
@@ -13,6 +13,7 @@ use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::chatwidget::tests::set_fast_mode_test_catalog;
use crate::file_search::FileSearchManager;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
@@ -85,6 +86,7 @@ use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::prelude::Line;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -4159,6 +4161,32 @@ async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() {
assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19");
}
#[tokio::test]
async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new(
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(),
Path::new("/tmp"),
))];
let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40);
app.chat_widget
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
app.chat_widget
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
let width = app.chat_widget.history_wrap_width(/*width*/ 40);
assert!(width < 40);
let with_pet = app.render_transcript_lines_for_reflow(width);
assert!(
with_pet.lines.len() > without_pet.lines.len(),
"expected pet-enabled transcript reflow to wrap earlier"
);
}
#[tokio::test]
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
+3 -1
View File
@@ -259,7 +259,9 @@ impl App {
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
let width = self
.chat_widget
.history_wrap_width(tui.terminal.last_known_screen_size.width);
for cell in &self.transcript_cells {
tui.insert_history_lines_with_wrap_policy(
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
+32
View File
@@ -318,6 +318,38 @@ pub(crate) enum AppEvent {
url: String,
},
/// Persist a pet selection and reload the ambient pet.
PetSelected {
pet_id: String,
},
/// Persist terminal pets as disabled and remove the ambient pet.
PetDisabled,
/// Start loading the side preview for the pet picker.
PetPreviewRequested {
pet_id: String,
},
/// Result of loading the side preview for the pet picker.
PetPreviewLoaded {
request_id: u64,
result: Result<crate::pets::AmbientPet, String>,
},
/// Result of loading the selected ambient pet before config persistence.
PetSelectionLoaded {
request_id: u64,
pet_id: String,
result: Result<Option<crate::pets::AmbientPet>, String>,
},
/// Result of restoring the configured ambient pet during startup.
ConfiguredPetLoaded {
pet_id: String,
result: Result<Option<crate::pets::AmbientPet>, String>,
},
/// Refresh app connector state and mention bindings.
RefreshConnectors {
force_refetch: bool,
+104 -20
View File
@@ -800,6 +800,14 @@ impl ChatComposer {
self.windows_degraded_sandbox_active = enabled;
}
fn layout_areas(&self, area: Rect) -> [Rect; 4] {
self.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0)
}
fn layout_areas_with_textarea_right_reserve(
&self,
area: Rect,
textarea_right_reserve: u16,
) -> [Rect; 4] {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
@@ -825,7 +833,7 @@ impl ChatComposer {
/*top*/ 1,
LIVE_PREFIX_COLS,
/*bottom*/ 1,
/*right*/ 1,
/*right*/ 1u16.saturating_add(textarea_right_reserve),
));
let remote_images_height = self
.remote_images_lines(textarea_rect.width)
@@ -855,7 +863,15 @@ impl ChatComposer {
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0)
}
pub(crate) fn cursor_pos_with_textarea_right_reserve(
&self,
area: Rect,
textarea_right_reserve: u16,
) -> Option<(u16, u16)> {
if !self.input_enabled || self.selected_remote_image_index.is_some() {
return None;
}
@@ -863,7 +879,8 @@ impl ChatComposer {
return Some(pos);
}
let [_, _, textarea_rect, _] = self.layout_areas(area);
let [_, _, textarea_rect, _] =
self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
@@ -4459,17 +4476,7 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled || self.selected_remote_image_index.is_some() {
return None;
}
if let Some(pos) = self.history_search_cursor_pos(area) {
return Some(pos);
}
let [_, _, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0)
}
fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle {
@@ -4481,6 +4488,20 @@ impl Renderable for ChatComposer {
}
fn desired_height(&self, width: u16) -> u16 {
self.desired_height_with_textarea_right_reserve(width, /*textarea_right_reserve*/ 0)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, /*mask_char*/ None);
}
}
impl ChatComposer {
pub(crate) fn desired_height_with_textarea_right_reserve(
&self,
width: u16,
textarea_right_reserve: u16,
) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = self
.custom_footer_height()
@@ -4488,7 +4509,8 @@ impl Renderable for ChatComposer {
let footer_spacing = Self::footer_spacing(footer_hint_height);
let footer_total_height = footer_hint_height + footer_spacing;
const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1;
let inner_width = width.saturating_sub(COLS_WITH_MARGIN);
let inner_width =
width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve));
let remote_images_height: u16 = self
.remote_images_lines(inner_width)
.len()
@@ -4507,16 +4529,24 @@ impl Renderable for ChatComposer {
ActivePopup::MentionV2(c) => c.calculate_required_height(width),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_mask(area, buf, /*mask_char*/ None);
}
}
impl ChatComposer {
pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option<char>) {
self.render_with_mask_and_textarea_right_reserve(
area, buf, mask_char, /*textarea_right_reserve*/ 0,
);
}
pub(crate) fn render_with_mask_and_textarea_right_reserve(
&self,
area: Rect,
buf: &mut Buffer,
mask_char: Option<char>,
textarea_right_reserve: u16,
) {
let [composer_rect, remote_images_rect, textarea_rect, popup_rect] =
self.layout_areas(area);
self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve);
match &self.active_popup {
ActivePopup::Command(popup) => {
popup.render_ref(popup_rect, buf);
@@ -7873,6 +7903,60 @@ mod tests {
}
}
#[test]
fn slash_popup_pets_for_pet_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']);
let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal");
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.expect("draw composer");
insta::assert_snapshot!("slash_popup_pet", terminal.backend());
}
#[test]
fn slash_popup_pets_for_pet_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "pets")
}
Some(CommandItem::ServiceTier(command)) => {
panic!("expected pets command, got service tier {command:?}")
}
None => panic!("no selected command for '/pet'"),
},
_ => panic!("slash popup not active after typing '/pet'"),
}
}
#[test]
fn service_tier_slash_command_dispatches_from_catalog_name() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+98 -1
View File
@@ -106,6 +106,7 @@ pub(crate) use footer::GoalStatusIndicator;
pub(crate) use footer::goal_status_indicator_line;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::ListSelectionView;
pub(crate) use list_selection_view::OnSelectionChangedCallback;
pub(crate) use list_selection_view::SelectionRowDisplay;
pub(crate) use list_selection_view::SelectionToggle;
pub(crate) use list_selection_view::SelectionViewParams;
@@ -1129,6 +1130,20 @@ impl BottomPane {
.and_then(|view| view.active_tab_id())
}
pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool {
let is_match = self
.view_stack
.last()
.is_some_and(|view| view.view_id() == Some(view_id));
if !is_match {
return false;
}
self.view_stack.pop();
self.request_redraw();
true
}
/// Update the pending-input preview shown above the composer.
pub(crate) fn set_pending_input_preview(
&mut self,
@@ -1529,6 +1544,13 @@ impl BottomPane {
}
fn as_renderable(&'_ self) -> RenderableItem<'_> {
self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0)
}
fn as_renderable_with_composer_right_reserve(
&'_ self,
composer_right_reserve: u16,
) -> RenderableItem<'_> {
if let Some(view) = self.active_view() {
RenderableItem::Borrowed(view)
} else {
@@ -1570,11 +1592,56 @@ impl BottomPane {
}
let mut flex2 = FlexRenderable::new();
flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into()));
flex2.push(/*flex*/ 0, RenderableItem::Borrowed(&self.composer));
let composer: RenderableItem<'_> = if composer_right_reserve == 0 {
RenderableItem::Borrowed(&self.composer)
} else {
RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable {
composer: &self.composer,
right_reserve: composer_right_reserve,
}))
};
flex2.push(/*flex*/ 0, composer);
RenderableItem::Owned(Box::new(flex2))
}
}
pub(crate) fn render_with_composer_right_reserve(
&self,
area: Rect,
buf: &mut Buffer,
composer_right_reserve: u16,
) {
self.as_renderable_with_composer_right_reserve(composer_right_reserve)
.render(area, buf);
}
pub(crate) fn desired_height_with_composer_right_reserve(
&self,
width: u16,
composer_right_reserve: u16,
) -> u16 {
self.as_renderable_with_composer_right_reserve(composer_right_reserve)
.desired_height(width)
}
pub(crate) fn cursor_pos_with_composer_right_reserve(
&self,
area: Rect,
composer_right_reserve: u16,
) -> Option<(u16, u16)> {
self.as_renderable_with_composer_right_reserve(composer_right_reserve)
.cursor_pos(area)
}
pub(crate) fn cursor_style_with_composer_right_reserve(
&self,
area: Rect,
composer_right_reserve: u16,
) -> crossterm::cursor::SetCursorStyle {
self.as_renderable_with_composer_right_reserve(composer_right_reserve)
.cursor_style(area)
}
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) {
if self.composer.set_status_line(status_line) {
self.request_redraw();
@@ -1610,6 +1677,36 @@ impl BottomPane {
}
}
struct ChatComposerRightReserveRenderable<'a> {
composer: &'a chat_composer::ChatComposer,
right_reserve: u16,
}
impl Renderable for ChatComposerRightReserveRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.composer.render_with_mask_and_textarea_right_reserve(
area,
buf,
/*mask_char*/ None,
self.right_reserve,
);
}
fn desired_height(&self, width: u16) -> u16 {
self.composer
.desired_height_with_textarea_right_reserve(width, self.right_reserve)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.composer
.cursor_pos_with_textarea_right_reserve(area, self.right_reserve)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.composer.cursor_style(area)
}
}
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
@@ -0,0 +1,9 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› /pet "
" "
" "
" /pets choose or hide the terminal pet "
+164 -7
View File
@@ -182,7 +182,10 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Text;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use tokio::sync::mpsc::UnboundedSender;
use tracing::debug;
@@ -203,6 +206,8 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading";
const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2;
const TUI_STUB_MESSAGE: &str = "Not available in TUI yet.";
/// Choose the keybinding used to edit the most-recently queued message.
@@ -334,6 +339,7 @@ use self::interrupts::InterruptManager;
mod keymap_picker;
mod mcp_startup;
use self::mcp_startup::McpStartupStatus;
mod pets;
mod session_header;
use self::session_header::SessionHeader;
mod hooks;
@@ -743,6 +749,15 @@ pub(crate) struct ChatWidget {
review: ReviewState,
// Active hook runs render in a dedicated live cell so they can run alongside tools.
active_hook_cell: Option<HookCell>,
// Ambient companion rendered over the transcript area, never inside the footer rows.
ambient_pet: Option<crate::pets::AmbientPet>,
pet_picker_preview_state: crate::pets::PetPickerPreviewState,
pet_picker_preview_pet: Option<crate::pets::AmbientPet>,
pet_picker_preview_request_id: u64,
pet_picker_preview_image_visible: std::cell::Cell<bool>,
pet_selection_load_request_id: u64,
#[cfg(test)]
pet_image_support_override: Option<crate::pets::PetImageSupport>,
thread_id: Option<ThreadId>,
/// Nudge dismissals that should survive draft edits within the current thread scope.
///
@@ -2272,6 +2287,10 @@ impl ChatWidget {
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Running,
/*body*/ None,
);
self.request_redraw();
}
@@ -2365,6 +2384,10 @@ impl ChatWidget {
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.unified_exec_wait_streak = None;
if !from_replay {
let body = Notification::agent_turn_preview(&notification_response);
self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body);
}
self.request_redraw();
let had_pending_steers = !self.input_queue.pending_steers.is_empty();
@@ -2849,6 +2872,10 @@ impl ChatWidget {
self.input_queue.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_error_event(message));
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Failed,
/*body*/ None,
);
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
@@ -4053,6 +4080,9 @@ impl ChatWidget {
self.update_due_hook_visibility();
self.schedule_hook_timer_if_needed();
self.bottom_pane.pre_draw_tick();
if let Some(pet) = self.ambient_pet.as_ref() {
pet.schedule_next_frame();
}
self.refresh_plan_mode_nudge();
self.refresh_goal_status_indicator_for_time_tick();
if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action {
@@ -4397,6 +4427,10 @@ impl ChatWidget {
};
self.bottom_pane
.push_approval_request(request, &self.config.features);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
@@ -4413,6 +4447,10 @@ impl ChatWidget {
};
self.bottom_pane
.push_approval_request(request, &self.config.features);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
self.notify(Notification::EditApprovalRequested {
cwd: self.config.cwd.to_path_buf(),
@@ -4471,12 +4509,20 @@ impl ChatWidget {
}
}
}
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) {
self.bottom_pane
.push_approval_request(request, &self.config.features);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
@@ -4486,6 +4532,10 @@ impl ChatWidget {
) {
self.bottom_pane
.push_mcp_server_elicitation_request(request);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
@@ -4500,6 +4550,10 @@ impl ChatWidget {
};
self.notify(Notification::PlanModePrompt { title });
self.bottom_pane.push_user_input_request(ev);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
@@ -4514,6 +4568,10 @@ impl ChatWidget {
};
self.bottom_pane
.push_approval_request(request, &self.config.features);
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Waiting,
/*body*/ None,
);
self.request_redraw();
}
@@ -4774,6 +4832,12 @@ impl ChatWidget {
&chat_keymap.edit_queued_message,
current_terminal_info,
);
pets::start_configured_pet_load_if_needed(
&config,
/*ambient_pet_missing*/ true,
frame_requester.clone(),
app_event_tx.clone(),
);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
@@ -4848,6 +4912,14 @@ impl ChatWidget {
status_state: StatusState::default(),
review: ReviewState::default(),
active_hook_cell: None,
ambient_pet: None,
pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(),
pet_picker_preview_pet: None,
pet_picker_preview_request_id: 0,
pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false),
pet_selection_load_request_id: 0,
#[cfg(test)]
pet_image_support_override: None,
thread_id: None,
dismissed_plan_mode_nudge_scopes: HashSet::new(),
thread_name: None,
@@ -9371,6 +9443,11 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn add_warning_message(&mut self, message: String) {
self.add_to_history(history_cell::new_warning_event(message));
self.request_redraw();
}
pub(crate) fn add_error_message(&mut self, message: String) {
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
@@ -9777,6 +9854,8 @@ impl ChatWidget {
if width == 0 {
None
} else {
let width = u16::try_from(width).unwrap_or(u16::MAX);
let width = usize::from(self.history_wrap_width(width));
Some(crate::width::usable_content_width(width, reserved_cols).unwrap_or(1))
}
})
@@ -10407,17 +10486,22 @@ impl ChatWidget {
}
fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell.as_ref(),
top: 1,
right: active_cell_right_reserve,
})),
None => RenderableItem::Owned(Box::new(())),
};
let active_hook_cell_renderable = match &self.active_hook_cell {
Some(cell) if cell.should_render() => {
RenderableItem::Borrowed(cell).inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
))
RenderableItem::Owned(Box::new(TranscriptAreaRenderable {
child: cell,
top: 1,
right: active_cell_right_reserve,
}))
}
_ => RenderableItem::Owned(Box::new(())),
};
@@ -10426,7 +10510,11 @@ impl ChatWidget {
flex.push(/*flex*/ 0, active_hook_cell_renderable);
flex.push(
/*flex*/ 0,
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: active_cell_right_reserve,
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
@@ -10434,6 +10522,75 @@ impl ChatWidget {
}
}
struct BottomPaneComposerReserveRenderable<'a> {
bottom_pane: &'a BottomPane,
right_reserve: u16,
}
impl Renderable for BottomPaneComposerReserveRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.bottom_pane
.render_with_composer_right_reserve(area, buf, self.right_reserve);
}
fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane
.desired_height_with_composer_right_reserve(width, self.right_reserve)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane
.cursor_pos_with_composer_right_reserve(area, self.right_reserve)
}
fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle {
self.bottom_pane
.cursor_style_with_composer_right_reserve(area, self.right_reserve)
}
}
struct TranscriptAreaRenderable<'a> {
child: &'a dyn HistoryCell,
top: u16,
right: u16,
}
impl Renderable for TranscriptAreaRenderable<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let area = self.child_area(area);
let lines = self.child.display_lines(area.width);
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let y = if area.height == 0 {
0
} else {
let overflow = paragraph
.line_count(area.width)
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
Clear.render(area, buf);
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let child_width = width.saturating_sub(self.right).max(1);
HistoryCell::desired_height(self.child, child_width) + self.top
}
}
impl TranscriptAreaRenderable<'_> {
fn child_area(&self, area: Rect) -> Rect {
let y = area.y.saturating_add(self.top);
let height = area.height.saturating_sub(self.top);
Rect::new(
area.x,
y,
area.width.saturating_sub(self.right).max(1),
height,
)
}
}
#[cfg(not(target_os = "linux"))]
impl ChatWidget {
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
+335
View File
@@ -0,0 +1,335 @@
//! Chat widget helpers for ambient terminal pets and the pets picker.
use super::*;
use codex_config::types::TuiPetAnchor;
pub(super) fn load_ambient_pet(
config: &Config,
frame_requester: FrameRequester,
) -> Option<crate::pets::AmbientPet> {
let selected_pet = config.tui_pet.as_deref()?;
if selected_pet == crate::pets::DISABLED_PET_ID {
return None;
}
crate::pets::AmbientPet::load(
Some(selected_pet),
&config.codex_home,
frame_requester,
config.animations,
)
.ok()
}
pub(super) fn start_configured_pet_load_if_needed(
config: &Config,
ambient_pet_missing: bool,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
) {
let Some(pet_id) = config.tui_pet.clone() else {
return;
};
if pet_id == crate::pets::DISABLED_PET_ID || !ambient_pet_missing {
return;
}
let codex_home = config.codex_home.clone();
let animations_enabled = config.animations;
spawn_pet_load(move || {
let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home)
.and_then(|()| {
crate::pets::AmbientPet::load(
Some(&pet_id),
&codex_home,
frame_requester,
animations_enabled,
)
})
.map(Some)
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::ConfiguredPetLoaded { pet_id, result });
});
}
impl ChatWidget {
pub(super) fn set_ambient_pet_notification(
&mut self,
kind: crate::pets::PetNotificationKind,
body: Option<String>,
) {
if let Some(pet) = self.ambient_pet.as_mut() {
pet.set_notification(kind, body);
}
}
pub(crate) fn ambient_pet_image_enabled(&self) -> bool {
self.ambient_pet
.as_ref()
.is_some_and(crate::pets::AmbientPet::image_enabled)
}
pub(crate) fn disable_ambient_pet_for_session(&mut self) {
self.ambient_pet = None;
self.request_redraw();
}
pub(crate) fn ambient_pet_draw(
&self,
area: Rect,
composer_bottom_y: u16,
) -> Option<crate::pets::AmbientPetDraw> {
if !self.bottom_pane.no_modal_or_popup_active() {
return None;
}
let anchor_bottom_y = match self.config.tui_pet_anchor {
TuiPetAnchor::Composer => composer_bottom_y,
TuiPetAnchor::ScreenBottom => area.bottom(),
};
self.ambient_pet
.as_ref()?
.draw_request(area, anchor_bottom_y)
}
pub(super) fn ambient_pet_wrap_reserved_cols(&self) -> u16 {
self.ambient_pet
.as_ref()
.filter(|pet| pet.image_enabled())
.map(|pet| {
pet.image_columns()
.saturating_add(AMBIENT_PET_WRAP_GAP_COLUMNS)
})
.unwrap_or(0)
}
pub(crate) fn history_wrap_width(&self, width: u16) -> u16 {
width
.saturating_sub(self.ambient_pet_wrap_reserved_cols())
.max(1)
}
pub(crate) fn pet_picker_preview_draw(&self) -> Option<crate::pets::AmbientPetDraw> {
self.bottom_pane
.selected_index_for_active_view(crate::pets::PET_PICKER_VIEW_ID)?;
let area = self.pet_picker_preview_state.area()?;
let request = self
.pet_picker_preview_pet
.as_ref()?
.preview_draw_request(area)?;
self.pet_picker_preview_image_visible.set(true);
Some(request)
}
pub(crate) fn should_clear_pet_picker_preview_image(&self) -> bool {
self.pet_picker_preview_image_visible.replace(false)
}
pub(crate) fn fail_pet_picker_preview_render(&mut self, message: String) {
self.pet_picker_preview_state.set_error(message);
self.pet_picker_preview_pet = None;
self.request_redraw();
}
pub(crate) fn open_pets_picker(&mut self) {
if self.warn_if_pets_unsupported() {
return;
}
self.pet_picker_preview_state.clear();
self.pet_picker_preview_pet = None;
let params = crate::pets::build_pet_picker_params(
self.config.tui_pet.as_deref(),
&self.config.codex_home,
self.pet_picker_preview_state.clone(),
);
self.bottom_pane.show_selection_view(params);
let initial_pet_id = self
.config
.tui_pet
.as_deref()
.unwrap_or(crate::pets::DEFAULT_PET_ID)
.to_string();
self.start_pet_picker_preview(initial_pet_id);
}
pub(crate) fn select_pet_by_id(&mut self, pet_id: String) {
if self.warn_if_pets_unsupported() {
return;
}
self.app_event_tx.send(AppEvent::PetSelected { pet_id });
}
fn warn_if_pets_unsupported(&mut self) -> bool {
let support = self.pet_image_support();
let Some(message) = support.unsupported_message() else {
return false;
};
self.add_warning_message(message.to_string());
true
}
fn pet_image_support(&self) -> crate::pets::PetImageSupport {
#[cfg(test)]
if let Some(support) = self.pet_image_support_override {
return support;
}
#[cfg(test)]
return crate::pets::PetImageSupport::Unsupported(
crate::pets::PetImageUnsupportedReason::Terminal,
);
#[cfg(not(test))]
crate::pets::detect_pet_image_support()
}
/// Set the pet preselected by the TUI picker in the widget's config copy.
pub(crate) fn set_tui_pet(&mut self, pet: Option<String>) {
self.config.tui_pet = pet;
self.ambient_pet = load_ambient_pet(&self.config, self.frame_requester.clone());
self.apply_ambient_pet_image_support_override_for_tests();
self.request_redraw();
}
pub(crate) fn set_tui_pet_loaded(
&mut self,
pet: Option<String>,
ambient_pet: Option<crate::pets::AmbientPet>,
) {
self.config.tui_pet = pet;
self.ambient_pet = ambient_pet;
self.apply_ambient_pet_image_support_override_for_tests();
self.request_redraw();
}
#[cfg(test)]
fn apply_ambient_pet_image_support_override_for_tests(&mut self) {
if let Some(support) = self.pet_image_support_override
&& let Some(pet) = self.ambient_pet.as_mut()
{
pet.set_image_support_for_tests(support);
}
}
#[cfg(not(test))]
fn apply_ambient_pet_image_support_override_for_tests(&mut self) {}
pub(crate) fn start_pet_picker_preview(&mut self, pet_id: String) {
self.pet_picker_preview_request_id =
self.pet_picker_preview_request_id.wrapping_add(/*rhs*/ 1);
let request_id = self.pet_picker_preview_request_id;
self.pet_picker_preview_pet = None;
if pet_id == crate::pets::DISABLED_PET_ID {
self.pet_picker_preview_state.set_disabled();
self.request_redraw();
return;
}
self.pet_picker_preview_state.set_loading();
self.request_redraw();
let codex_home = self.config.codex_home.clone();
let frame_requester = self.frame_requester.clone();
let tx = self.app_event_tx.clone();
spawn_pet_load(move || {
let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home)
.and_then(|()| {
crate::pets::AmbientPet::load(
Some(&pet_id),
&codex_home,
frame_requester,
/*animations_enabled*/ false,
)
})
.map_err(|err| err.to_string());
tx.send(AppEvent::PetPreviewLoaded { request_id, result });
});
}
pub(crate) fn finish_pet_picker_preview_load(
&mut self,
request_id: u64,
result: Result<crate::pets::AmbientPet, String>,
) {
if request_id != self.pet_picker_preview_request_id {
return;
}
match result {
Ok(pet) => {
self.pet_picker_preview_state.set_ready();
self.pet_picker_preview_pet = Some(pet);
#[cfg(test)]
if let Some(support) = self.pet_image_support_override
&& let Some(pet) = self.pet_picker_preview_pet.as_mut()
{
pet.set_image_support_for_tests(support);
}
}
Err(message) => {
self.pet_picker_preview_state.set_error(message);
self.pet_picker_preview_pet = None;
}
}
self.request_redraw();
}
pub(crate) fn show_pet_selection_loading_popup(&mut self) -> u64 {
self.pet_selection_load_request_id =
self.pet_selection_load_request_id.wrapping_add(/*rhs*/ 1);
self.pet_picker_preview_state.clear();
self.pet_picker_preview_pet = None;
self.bottom_pane.show_selection_view(SelectionViewParams {
view_id: Some(PET_SELECTION_LOADING_VIEW_ID),
title: Some("Loading Pet".to_string()),
subtitle: Some("Preparing the terminal pet.".to_string()),
items: vec![SelectionItem {
name: "Loading selected pet...".to_string(),
is_disabled: true,
..Default::default()
}],
..Default::default()
});
self.pet_selection_load_request_id
}
pub(crate) fn finish_pet_selection_loading_popup(&mut self, request_id: u64) -> bool {
if request_id != self.pet_selection_load_request_id {
return false;
}
self.bottom_pane
.dismiss_active_view_if_id(PET_SELECTION_LOADING_VIEW_ID);
true
}
#[cfg(test)]
pub(crate) fn set_pet_image_support_for_tests(
&mut self,
support: crate::pets::PetImageSupport,
) {
self.pet_image_support_override = Some(support);
self.apply_ambient_pet_image_support_override_for_tests();
}
#[cfg(test)]
pub(crate) fn install_test_ambient_pet_for_tests(&mut self, animations_enabled: bool) {
self.set_tui_pet_loaded(
Some("test".to_string()),
Some(crate::pets::test_ambient_pet(
self.frame_requester.clone(),
animations_enabled,
)),
);
}
}
fn spawn_pet_load(f: impl FnOnce() + Send + 'static) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
std::mem::drop(handle.spawn_blocking(f));
} else {
let _ = std::thread::spawn(f);
}
}
+16 -1
View File
@@ -405,6 +405,9 @@ impl ChatWidget {
SlashCommand::Theme => {
self.open_theme_picker();
}
SlashCommand::Pets => {
self.open_pets_picker();
}
SlashCommand::Ps => {
self.add_ps_output();
}
@@ -781,6 +784,17 @@ impl ChatWidget {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
}
SlashCommand::Pets
if matches!(
args.trim().to_ascii_lowercase().as_str(),
"disable" | "disabled" | "hide" | "hidden" | "off" | "none"
) =>
{
self.app_event_tx.send(AppEvent::PetDisabled);
}
SlashCommand::Pets if !trimmed.is_empty() => {
self.select_pet_by_id(args);
}
_ => self.dispatch_command(cmd),
}
if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal {
@@ -970,7 +984,8 @@ impl ChatWidget {
| SlashCommand::Hooks
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme => QueueDrain::Stop,
| SlashCommand::Theme
| SlashCommand::Pets => QueueDrain::Stop,
}
}
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: terminal.backend().vt100().screen().contents()
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: contents
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: terminal.backend().vt100().screen().contents()
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/permissions.rs
expression: popup
---
Update Model Permissions
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
" "
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
---
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Upload logs?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Upload logs?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/permissions.rs
expression: popup
---
Enable full access?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Enable memories?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Reasoning Level for gpt-5.4
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Enable subagents?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Personality
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/plan_mode.rs
expression: popup
---
Implement this plan?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/plan_mode.rs
expression: popup
---
Implement this plan?
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Plugins
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: strip_osc8_for_snapshot(&popup)
---
Plugins
Figma · Can be installed · ChatGPT Marketplace
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: strip_osc8_for_snapshot(&popup)
---
Plugins
Figma · Installed · ChatGPT Marketplace
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: popup
---
Approaching rate limits
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Settings
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Microphone
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
" "
@@ -0,0 +1,25 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
Select Pet
Choose a pet to wake in the terminal.
Type to filter pets...
Disable terminal pets
BSOD A tiny blue-screen
gremlin
› Codex The original Codex
companion
Dewey A tidy duck for calm Loading preview...
workspace days
Fireball Hot path energy for
fast iteration
Null Signal Quiet signal from the
void
Rocky A steady rock when
the diff gets large
Seedy Small green shoots
for new ideas
Press enter to confirm or esc to go back
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
" "
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
" "
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
" "
@@ -149,7 +149,7 @@ async fn guardian_approved_exec_renders_approved_request() {
let width: u16 = 120;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 12;
let vt_height: u16 = ui_height.saturating_add(1).max(12);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
@@ -227,7 +227,7 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
let width: u16 = 110;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 12;
let vt_height: u16 = ui_height.saturating_add(1).max(12);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
@@ -412,7 +412,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() {
let width: u16 = 140;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 16;
let vt_height: u16 = ui_height.saturating_add(1).max(16);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
@@ -493,7 +493,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot
let width: u16 = 140;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 16;
let vt_height: u16 = ui_height.saturating_add(1).max(16);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
@@ -1314,6 +1314,7 @@ pub(super) fn plugins_test_summary(
) -> PluginSummary {
PluginSummary {
id: id.to_string(),
remote_plugin_id: None,
name: name.to_string(),
share_context: None,
source: PluginSource::Local {
@@ -102,7 +102,7 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
let width: u16 = 120;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 10;
let vt_height: u16 = ui_height.saturating_add(1).max(10);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
@@ -1,6 +1,19 @@
use super::*;
use crate::bottom_pane::slash_commands::ServiceTierCommand;
use pretty_assertions::assert_eq;
use serial_test::serial;
fn force_pet_image_support(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
}
fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported(
crate::pets::PetImageUnsupportedReason::Tmux,
));
}
fn fast_tier_command() -> ServiceTierCommand {
ServiceTierCommand {
@@ -1819,6 +1832,108 @@ async fn slash_resume_with_arg_requests_named_session() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
#[serial]
async fn slash_pets_opens_picker() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_pet_image_support(&mut chat);
chat.dispatch_command(SlashCommand::Pets);
assert!(chat.bottom_pane.has_active_view());
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("slash_pets_picker", popup);
}
#[tokio::test]
#[serial]
async fn slash_pets_with_arg_selects_named_pet() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_pet_image_support(&mut chat);
chat.bottom_pane
.set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::PetSelected { pet_id }) if pet_id == "chefito"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
#[serial]
async fn slash_pets_disable_disables_pets_even_on_unsupported_terminal() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_tmux_pet_image_unsupported(&mut chat);
chat.bottom_pane
.set_composer_text("/pets disable".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
#[serial]
async fn slash_pet_hide_disables_pets_even_on_unsupported_terminal() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_tmux_pet_image_unsupported(&mut chat);
chat.bottom_pane
.set_composer_text("/pet hide".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
#[serial]
async fn slash_pets_on_unsupported_terminal_warns_without_picker() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_tmux_pet_image_unsupported(&mut chat);
chat.dispatch_command(SlashCommand::Pets);
assert!(!chat.bottom_pane.has_active_view());
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Pets are disabled in tmux."));
assert!(rendered.contains("outside tmux"));
}
#[tokio::test]
#[serial]
async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
force_tmux_pet_image_unsupported(&mut chat);
chat.bottom_pane
.set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Pets are disabled in tmux."));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_fork_requests_current_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/approval_requests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -1,6 +1,15 @@
use super::*;
use crate::bottom_pane::goal_status_indicator_line;
use pretty_assertions::assert_eq;
use ratatui::backend::TestBackend;
use serial_test::serial;
fn enable_test_ambient_pet(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
}
/// Receiving a token usage update without usage clears the context indicator.
#[tokio::test]
@@ -392,10 +401,12 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() {
}
#[tokio::test]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
async fn configured_pet_load_is_deferred_until_after_construction() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let cfg = test_config().await;
let mut cfg = test_config().await;
cfg.tui_pet = Some(crate::pets::DEFAULT_PET_ID.to_string());
crate::pets::write_test_pack(&cfg.codex_home);
let resolved_model = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref());
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
@@ -419,9 +430,21 @@ async fn helpers_are_available_and_do_not_panic() {
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
let mut w = ChatWidget::new_with_app_event(init);
// Basic construction sanity.
let _ = &mut w;
let chat = ChatWidget::new_with_app_event(init);
assert!(!chat.ambient_pet_image_enabled());
let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.unwrap()
.unwrap();
assert_matches!(
event,
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID);
assert!(result.unwrap().is_some());
}
);
}
#[tokio::test]
@@ -1352,6 +1375,223 @@ async fn ui_snapshots_small_heights_task_running() {
}
}
#[tokio::test]
#[serial]
async fn ambient_pet_stays_hidden_until_a_pet_is_selected() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
assert!(chat.ambient_pet.is_none());
crate::pets::write_test_pack(&chat.config.codex_home);
chat.set_tui_pet(Some("codex".to_string()));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 20,
);
let draw = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request");
assert_eq!(draw.x, 51);
assert_eq!(draw.y, 14);
assert_eq!(draw.columns, 9);
assert_eq!(draw.rows, 5);
assert_eq!(
draw.y.saturating_add(draw.rows),
area.bottom().saturating_sub(/*rhs*/ 1)
);
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Thinking**");
let draw_with_status = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request with status");
assert_eq!(draw_with_status.y, draw.y);
assert_eq!(
draw_with_status.y.saturating_add(draw_with_status.rows),
area.bottom().saturating_sub(/*rhs*/ 1)
);
}
#[tokio::test]
#[serial]
async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() {
use codex_config::types::TuiPetAnchor;
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
let terminal_area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24,
);
let composer_bottom_y = 20;
let default_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("composer-anchored pet draw request");
assert_eq!(default_draw.y, 14);
chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom;
let screen_bottom_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("screen-bottom anchored pet draw request");
assert_eq!(screen_bottom_draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_can_be_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
assert!(chat.ambient_pet.is_none());
}
#[tokio::test]
#[serial]
async fn ambient_pet_reserves_history_wrap_width() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
assert_eq!(chat.history_wrap_width(/*width*/ 80), 69);
chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
assert_eq!(chat.history_wrap_width(/*width*/ 80), 80);
}
#[tokio::test]
#[serial]
async fn ambient_pet_reduces_stream_width_and_composer_text_width() {
use ratatui::Terminal;
let (mut with_pet, _with_pet_rx, _with_pet_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut with_pet);
with_pet.last_rendered_width.set(Some(80));
let stream_width_with_pet = with_pet.current_stream_width(/*reserved_cols*/ 2);
let (mut disabled, _disabled_rx, _disabled_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
disabled.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
disabled.last_rendered_width.set(Some(80));
let stream_width_without_pet = disabled.current_stream_width(/*reserved_cols*/ 2);
assert_eq!(
stream_width_with_pet,
crate::width::usable_content_width(/*total_width*/ 69, /*reserved_cols*/ 2)
);
assert_eq!(
stream_width_without_pet,
crate::width::usable_content_width(/*total_width*/ 80, /*reserved_cols*/ 2)
);
assert!(stream_width_with_pet < stream_width_without_pet);
let draft =
"Minim commodo esse elit Lorem exercitation elit ipsum proident labore. Esse culpa aliqua"
.to_string();
with_pet
.bottom_pane
.set_composer_text(draft.clone(), Vec::new(), Vec::new());
disabled
.bottom_pane
.set_composer_text(draft, Vec::new(), Vec::new());
let mut with_pet_terminal =
Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal");
with_pet_terminal
.draw(|f| with_pet.render(f.area(), f.buffer_mut()))
.expect("draw pet-enabled chat");
let mut disabled_terminal =
Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal");
disabled_terminal
.draw(|f| disabled.render(f.area(), f.buffer_mut()))
.expect("draw disabled-pet chat");
let pet_row = buffer_row_containing(with_pet_terminal.backend().buffer(), "Minim")
.expect("pet-enabled composer row should render draft");
let disabled_row = buffer_row_containing(disabled_terminal.backend().buffer(), "Minim")
.expect("disabled-pet composer row should render draft");
assert!(row_tail_is_blank(&pet_row, /*start_col*/ 69));
assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69));
}
fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option<String> {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer.cell((x, y)).expect("cell should exist").symbol())
.collect::<String>()
})
.find(|row| row.contains(text))
}
fn row_tail_is_blank(row: &str, start_col: usize) -> bool {
row.chars().skip(start_col).all(char::is_whitespace)
}
#[tokio::test]
#[serial]
async fn ambient_pet_draw_uses_terminal_screen_area_not_short_inline_viewport() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
assert!(
chat.ambient_pet_draw(
Rect::new(
/*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3,
),
/*composer_bottom_y*/ 24
)
.is_none(),
"a normal short inline viewport cannot fit the ambient pet"
);
let draw = chat
.ambient_pet_draw(
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24,
),
/*composer_bottom_y*/ 24,
)
.expect("full terminal screen has room for the ambient pet");
assert_eq!(draw.x, 71);
assert_eq!(draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_hides_notification_text_overlay() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
for (kind, label) in [
(crate::pets::PetNotificationKind::Running, "Running"),
(crate::pets::PetNotificationKind::Waiting, "Needs input"),
(crate::pets::PetNotificationKind::Review, "Ready"),
(crate::pets::PetNotificationKind::Failed, "Blocked"),
] {
chat.set_ambient_pet_notification(kind, /*body*/ None);
let mut terminal = Terminal::new(TestBackend::new(60, 20)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw ambient pet notification");
assert!(
!normalized_backend_snapshot(terminal.backend()).contains(label),
"did not expect {label} notification text to render"
);
}
}
// Snapshot test: status widget + approval modal active together
// The modal takes precedence visually; this captures the layout with a running
// task (status indicator active) while an approval request is shown.
+1
View File
@@ -111,6 +111,7 @@ mod clipboard_paste;
mod collaboration_modes;
mod color;
pub(crate) mod custom_terminal;
mod pets;
pub use custom_terminal::Terminal;
mod auto_review_denials;
mod cwd_prompt;
+528
View File
@@ -0,0 +1,528 @@
//! Ambient terminal rendering for the Codex companion.
//!
//! Ambient pets reuse the same extracted image frames as the full-screen viewer
//! but are rendered through a different ownership split: ratatui still owns the
//! transcript/composer layout, while the sprite itself is emitted through the
//! terminal image protocol after the frame draw completes.
//!
//! This module therefore owns two separate contracts:
//! choosing which animation frame should be visible for the current semantic
//! pet state, and translating that frame into a precise on-screen image request
//! that does not overlap reserved bottom-pane space. It does not persist pet
//! selection or decide when modal/popover UI should suppress the sprite.
#[cfg(test)]
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use ratatui::layout::Rect;
use crate::tui::FrameRequester;
use super::DEFAULT_PET_ID;
use super::frames;
use super::image_protocol::ImageProtocol;
use super::image_protocol::PetImageSupport;
#[cfg(not(test))]
use super::image_protocol::ProtocolSelection;
use super::model::Animation;
#[cfg(test)]
use super::model::AnimationFrame;
use super::model::Pet;
const PET_TARGET_HEIGHT_PX: u16 = 75;
const PET_COMPOSER_GAP_PX: u16 = 10;
const TERMINAL_ROW_HEIGHT_PX: u16 = 15;
const RUNNING_LIFETIME: Duration = Duration::from_secs(3 * 60);
const FAILED_LIFETIME: Duration = Duration::from_secs(60 * 60);
const WAITING_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const REVIEW_LIFETIME: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetNotificationKind {
Running,
Waiting,
Review,
Failed,
}
impl PetNotificationKind {
fn animation_name(self) -> &'static str {
match self {
Self::Running => "running",
Self::Waiting => "waiting",
Self::Review => "review",
Self::Failed => "failed",
}
}
fn label(self) -> &'static str {
match self {
Self::Running => "Running",
Self::Waiting => "Needs input",
Self::Review => "Ready",
Self::Failed => "Blocked",
}
}
fn fallback_body(self) -> &'static str {
match self {
Self::Running => "Thinking",
Self::Waiting => "Needs input",
Self::Review => "Ready",
Self::Failed => "Blocked",
}
}
fn lifetime(self) -> Duration {
match self {
Self::Running => RUNNING_LIFETIME,
Self::Waiting => WAITING_LIFETIME,
Self::Review => REVIEW_LIFETIME,
Self::Failed => FAILED_LIFETIME,
}
}
}
#[derive(Debug, Clone)]
struct PetNotification {
kind: PetNotificationKind,
body: String,
updated_at: Instant,
}
impl PetNotification {
fn new(kind: PetNotificationKind, body: Option<String>) -> Self {
Self {
kind,
body: body.unwrap_or_else(|| kind.fallback_body().to_string()),
updated_at: Instant::now(),
}
}
fn is_expired(&self, now: Instant) -> bool {
now.saturating_duration_since(self.updated_at) >= self.kind.lifetime()
}
}
#[derive(Debug, Clone)]
pub(crate) struct AmbientPetDraw {
pub(crate) frame: PathBuf,
pub(crate) protocol: ImageProtocol,
pub(crate) x: u16,
pub(crate) y: u16,
pub(crate) clear_top_y: u16,
pub(crate) columns: u16,
pub(crate) rows: u16,
pub(crate) height_px: u16,
pub(crate) sixel_dir: PathBuf,
}
#[derive(Debug)]
pub(crate) struct AmbientPet {
pet: Pet,
support: PetImageSupport,
frames: Vec<PathBuf>,
sixel_dir: PathBuf,
frame_requester: FrameRequester,
notification: Option<PetNotification>,
animation_started_at: Instant,
animations_enabled: bool,
}
impl AmbientPet {
/// Load the active ambient pet and prepare its frame cache.
///
/// This resolves the selected pet id, extracts per-frame PNGs into the
/// CODEX_HOME cache, and records the terminal protocol support snapshot used
/// for later draw requests. A caller that repeatedly recreates `AmbientPet`
/// instead of mutating one instance would lose animation timing continuity
/// and pay the frame-cache preparation cost more often than necessary.
pub(crate) fn load(
selected_pet: Option<&str>,
codex_home: &std::path::Path,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> Result<Self> {
let pet = Pet::load_with_codex_home(
selected_pet.unwrap_or(DEFAULT_PET_ID),
/*codex_home*/ Some(codex_home),
)
.with_context(|| "load ambient pet")?;
let cache_dir = codex_home
.join("cache")
.join("tui-pets")
.join("frame-cache")
.join(&pet.id)
.join(pet.frame_cache_key()?);
let frame_dir = cache_dir.join("frames");
let sixel_dir = cache_dir.join("sixel");
let frames = frames::prepare_png_frames(&pet, &frame_dir)?;
Ok(Self {
pet,
support: default_image_support(),
frames,
sixel_dir,
frame_requester,
notification: None,
animation_started_at: Instant::now(),
animations_enabled,
})
}
pub(crate) fn set_notification(&mut self, kind: PetNotificationKind, body: Option<String>) {
self.notification = Some(PetNotification::new(kind, body));
self.animation_started_at = Instant::now();
}
pub(crate) fn image_enabled(&self) -> bool {
self.support.protocol().is_some()
}
pub(crate) fn image_columns(&self) -> u16 {
self.image_size().columns
}
#[cfg(test)]
pub(crate) fn set_image_support_for_tests(&mut self, support: PetImageSupport) {
self.support = support;
}
pub(crate) fn schedule_next_frame(&self) {
if let Some(delay) = self.next_frame_delay() {
self.frame_requester.schedule_frame_in(delay);
}
}
fn next_frame_delay(&self) -> Option<Duration> {
if self.support.protocol().is_none() || !self.animations_enabled {
return None;
}
current_animation_frame(
self.current_animation()?,
self.animation_started_at.elapsed(),
)?
.delay
}
/// Build an image draw request for the ambient pet anchored above the composer.
///
/// Returning `None` means "do not render the sprite this frame", typically
/// because the terminal protocol is unavailable or the current layout cannot
/// fit the image without overlapping reserved UI. Callers should not try to
/// partially clip the image themselves; that would desynchronize the image
/// protocol output from the TUI's notion of cleared rows.
pub(crate) fn draw_request(
&self,
area: Rect,
composer_bottom_y: u16,
) -> Option<AmbientPetDraw> {
let protocol = self.support.protocol()?;
let size = self.image_size();
let notification = self.visible_notification(Instant::now());
let notification_height = notification.map_or(0, notification_height);
let required_height = size.rows.saturating_add(notification_height);
let sprite_bottom_y = composer_bottom_y.saturating_sub(composer_gap_rows());
if sprite_bottom_y < area.y.saturating_add(required_height) || area.width < size.columns {
return None;
}
let x = area.x + area.width.saturating_sub(size.columns);
let y = sprite_bottom_y.saturating_sub(size.rows);
Some(AmbientPetDraw {
frame: self.current_frame_path()?,
protocol,
x,
y,
clear_top_y: area.y,
columns: size.columns,
rows: size.rows,
height_px: size.height_px,
sixel_dir: self.sixel_dir.clone(),
})
}
/// Build a centered preview draw request for the `/pets` picker side pane.
///
/// The picker preview intentionally uses the first idle frame rather than
/// the live animation state so selection browsing stays stable and does not
/// require the full ambient animation lifecycle.
pub(crate) fn preview_draw_request(&self, area: Rect) -> Option<AmbientPetDraw> {
let protocol = self.support.protocol()?;
let size = self.image_size();
if area.width < size.columns || area.height < size.rows {
return None;
}
let y = area.y + area.height.saturating_sub(size.rows) / 2;
Some(AmbientPetDraw {
frame: self.first_idle_frame_path()?,
protocol,
x: area.x + area.width.saturating_sub(size.columns) / 2,
y,
clear_top_y: y,
columns: size.columns,
rows: size.rows,
height_px: size.height_px,
sixel_dir: self.sixel_dir.clone(),
})
}
fn visible_notification(&self, now: Instant) -> Option<&PetNotification> {
self.notification
.as_ref()
.filter(|notification| !notification.is_expired(now))
}
fn current_animation(&self) -> Option<&Animation> {
let animation_name = self
.visible_notification(Instant::now())
.map_or("idle", |notification| notification.kind.animation_name());
let animation = self
.pet
.animations
.get(animation_name)
.or_else(|| self.pet.animations.get("idle"))?;
if animation.loop_start.is_none() {
let elapsed = self.animation_started_at.elapsed();
if elapsed >= animation.total_duration()
&& let Some(fallback) = self.pet.animations.get(&animation.fallback)
{
return Some(fallback);
}
}
Some(animation)
}
fn current_frame_path(&self) -> Option<PathBuf> {
let sprite_index = self
.current_animation()
.and_then(|animation| {
if self.animations_enabled {
current_animation_frame(animation, self.animation_started_at.elapsed())
.map(|frame| frame.sprite_index)
} else {
animation.frames.first().map(|frame| frame.sprite_index)
}
})
.unwrap_or(0);
self.frame_path_for_sprite_index(sprite_index)
}
fn first_idle_frame_path(&self) -> Option<PathBuf> {
let sprite_index = self
.pet
.animations
.get("idle")
.and_then(|animation| animation.frames.first())
.map_or(0, |frame| frame.sprite_index);
self.frame_path_for_sprite_index(sprite_index)
}
fn frame_path_for_sprite_index(&self, sprite_index: usize) -> Option<PathBuf> {
self.frames
.get(sprite_index.min(self.frames.len().saturating_sub(1)))
.cloned()
}
fn image_size(&self) -> ImageSize {
let rows = (f64::from(PET_TARGET_HEIGHT_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX))
.round()
.max(/*other*/ 1.0) as u16;
let aspect = f64::from(self.pet.frame_height) / f64::from(self.pet.frame_width) * 0.52;
let columns = (f64::from(rows) / aspect).round() as u16;
ImageSize {
columns: columns.max(1),
rows,
height_px: PET_TARGET_HEIGHT_PX,
}
}
}
fn composer_gap_rows() -> u16 {
((f64::from(PET_COMPOSER_GAP_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX)).round() as u16)
.max(/*other*/ 1)
}
#[cfg(not(test))]
fn default_image_support() -> PetImageSupport {
ProtocolSelection::Auto.resolve()
}
#[cfg(test)]
fn default_image_support() -> PetImageSupport {
PetImageSupport::Unsupported(super::image_protocol::PetImageUnsupportedReason::Terminal)
}
#[derive(Debug, Clone, Copy)]
struct ImageSize {
columns: u16,
rows: u16,
height_px: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AnimationFrameTick {
sprite_index: usize,
delay: Option<Duration>,
}
fn current_animation_frame(animation: &Animation, elapsed: Duration) -> Option<AnimationFrameTick> {
if animation.frames.len() <= 1 {
return Some(AnimationFrameTick {
sprite_index: animation.frames.first()?.sprite_index,
delay: None,
});
}
let elapsed_nanos = elapsed.as_nanos();
if let Some(loop_start) = animation
.loop_start
.filter(|idx| *idx < animation.frames.len())
{
let total_nanos = animation.total_duration().as_nanos();
let prefix_nanos = animation.frames[..loop_start]
.iter()
.map(|frame| frame.duration.as_nanos())
.sum::<u128>();
let loop_nanos = animation.frames[loop_start..]
.iter()
.map(|frame| frame.duration.as_nanos())
.sum::<u128>();
let effective_elapsed = if elapsed_nanos >= total_nanos && loop_nanos > 0 {
prefix_nanos + elapsed_nanos.saturating_sub(prefix_nanos) % loop_nanos
} else {
elapsed_nanos
};
frame_at_elapsed(animation, effective_elapsed)
} else if elapsed_nanos >= animation.total_duration().as_nanos() {
Some(AnimationFrameTick {
sprite_index: animation.frames.last()?.sprite_index,
delay: None,
})
} else {
frame_at_elapsed(animation, elapsed_nanos)
}
}
fn frame_at_elapsed(animation: &Animation, elapsed_nanos: u128) -> Option<AnimationFrameTick> {
let mut remaining_elapsed = elapsed_nanos;
for frame in &animation.frames {
let frame_nanos = frame.duration.as_nanos().max(/*other*/ 1);
if remaining_elapsed < frame_nanos {
return Some(AnimationFrameTick {
sprite_index: frame.sprite_index,
delay: Some(nanos_to_duration(frame_nanos - remaining_elapsed)),
});
}
remaining_elapsed = remaining_elapsed.saturating_sub(frame_nanos);
}
Some(AnimationFrameTick {
sprite_index: animation.frames.last()?.sprite_index,
delay: None,
})
}
fn nanos_to_duration(nanos: u128) -> Duration {
Duration::from_nanos(nanos.min(u128::from(u64::MAX)) as u64)
}
fn notification_height(notification: &PetNotification) -> u16 {
if notification.body == notification.kind.label() {
1
} else {
2
}
}
#[cfg(test)]
pub(crate) fn test_ambient_pet(
frame_requester: FrameRequester,
animations_enabled: bool,
) -> AmbientPet {
AmbientPet {
pet: Pet {
id: "test".to_string(),
display_name: "Test".to_string(),
description: String::new(),
spritesheet_path: PathBuf::from("spritesheet.webp"),
frame_width: 192,
frame_height: 208,
columns: 8,
rows: 9,
frame_count: 72,
animations: HashMap::from([("idle".to_string(), test_animation())]),
},
support: PetImageSupport::Supported(ImageProtocol::Kitty),
frames: vec![PathBuf::from("frame-0.png"), PathBuf::from("frame-1.png")],
sixel_dir: PathBuf::new(),
frame_requester,
notification: None,
animation_started_at: Instant::now()
.checked_sub(Duration::from_millis(/*millis*/ 15))
.unwrap(),
animations_enabled,
}
}
#[cfg(test)]
fn test_animation() -> Animation {
Animation {
frames: vec![
AnimationFrame {
sprite_index: 0,
duration: Duration::from_millis(/*millis*/ 10),
},
AnimationFrame {
sprite_index: 1,
duration: Duration::from_millis(/*millis*/ 10),
},
],
loop_start: Some(/*loop_start*/ 0),
fallback: "idle".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_labels_match_codex_app_vocabulary() {
assert_eq!(PetNotificationKind::Running.label(), "Running");
assert_eq!(PetNotificationKind::Waiting.label(), "Needs input");
assert_eq!(PetNotificationKind::Review.label(), "Ready");
assert_eq!(PetNotificationKind::Failed.label(), "Blocked");
}
#[test]
fn animation_frame_uses_per_frame_duration() {
let animation = test_animation();
assert_eq!(
current_animation_frame(&animation, Duration::from_millis(/*millis*/ 15)),
Some(AnimationFrameTick {
sprite_index: 1,
delay: Some(Duration::from_millis(/*millis*/ 5)),
})
);
}
#[test]
fn reduced_motion_uses_stable_first_frame_and_schedules_no_follow_up() {
let pet = test_ambient_pet(
FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
assert_eq!(pet.current_frame_path(), Some(PathBuf::from("frame-0.png")));
assert_eq!(pet.next_frame_delay(), None);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Built-in pet asset acquisition and cache ownership.
//!
//! Unlike custom pets, built-in pets are not checked into the TUI package as
//! local spritesheets. The TUI resolves them from the public Codex pets CDN on
//! first use, verifies that the downloaded file has the expected spritesheet
//! geometry, and installs it into a versioned cache under CODEX_HOME.
//!
//! This module deliberately stops at "a validated spritesheet exists at this
//! path". Higher layers remain responsible for deciding when downloads are
//! allowed, when previews should block on them, and when a successfully loaded
//! built-in pet is safe to persist to config.
use std::fs;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use url::Url;
use uuid::Uuid;
use super::catalog;
const PET_PACK_VERSION: &str = "v1";
const PET_PACK_DIR: &str = "cache/tui-pets";
const PET_CDN_BASE_URL: &str = "https://persistent.oaistatic.com/codex/pets/v1";
const PET_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
const PET_MAX_DOWNLOAD_BYTES: u64 = 4 * 1024 * 1024;
pub(crate) fn builtin_spritesheet_path(codex_home: &Path, file: &str) -> PathBuf {
pack_dir(codex_home).join("assets").join(file)
}
/// Ensure that a built-in pet's spritesheet is present and structurally valid.
///
/// The cache key is the CDN-facing filename, so updating a built-in pet means
/// publishing a new versioned filename rather than mutating an existing one in
/// place. If a cached file is missing or invalid, this downloads a fresh copy,
/// validates the decoded image dimensions, and installs it atomically. Callers
/// should treat any error here as "the asset is unavailable", not as a partial
/// install they can safely ignore.
pub(crate) fn ensure_builtin_pet(codex_home: &Path, pet: catalog::BuiltinPet) -> Result<()> {
let destination = builtin_spritesheet_path(codex_home, pet.spritesheet_file);
if validate_cached_spritesheet(&destination).is_ok() {
return Ok(());
}
let url = builtin_pet_url(pet)?;
let bytes = download_bytes_with_limit(&url, PET_MAX_DOWNLOAD_BYTES)?;
let parent = destination
.parent()
.context("pet spritesheet path should include an assets directory")?;
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staging = destination.with_file_name(format!(
".{}.download-{}.webp",
pet.spritesheet_file,
Uuid::new_v4()
));
fs::write(&staging, &bytes).with_context(|| format!("write {}", staging.display()))?;
if let Err(err) = validate_cached_spritesheet(&staging) {
let _ = fs::remove_file(&staging);
return Err(err);
}
if install_downloaded_spritesheet(&staging, &destination).is_ok() {
return Ok(());
}
if validate_cached_spritesheet(&destination).is_ok() {
let _ = fs::remove_file(&staging);
return Ok(());
}
if destination.exists() {
fs::remove_file(&destination)
.with_context(|| format!("remove {}", destination.display()))?;
}
install_downloaded_spritesheet(&staging, &destination)
}
fn builtin_pet_url(pet: catalog::BuiltinPet) -> Result<String> {
let url = format!("{PET_CDN_BASE_URL}/{}", pet.spritesheet_file);
validate_download_url(&url)?;
Ok(url)
}
fn pack_dir(codex_home: &Path) -> PathBuf {
codex_home.join(PET_PACK_DIR).join(PET_PACK_VERSION)
}
fn download_bytes_with_limit(url: &str, max_bytes: u64) -> Result<Vec<u8>> {
validate_download_url(url)?;
let response = reqwest::blocking::Client::builder()
.timeout(PET_DOWNLOAD_TIMEOUT)
.build()
.context("build pet asset download client")?
.get(url)
.send()
.with_context(|| format!("download pet asset from {url}"))?
.error_for_status()
.with_context(|| format!("download pet asset from {url}"))?;
validate_download_url(response.url().as_str())?;
if response.content_length().is_some_and(|len| len > max_bytes) {
bail!("pet asset download from {url} exceeded {max_bytes} bytes");
}
let mut bytes = Vec::new();
response
.take(max_bytes.saturating_add(/*rhs*/ 1))
.read_to_end(&mut bytes)
.with_context(|| format!("read pet asset download from {url}"))?;
if bytes.len() as u64 > max_bytes {
bail!("pet asset download from {url} exceeded {max_bytes} bytes");
}
Ok(bytes)
}
fn install_downloaded_spritesheet(staging: &Path, destination: &Path) -> Result<()> {
fs::rename(staging, destination).with_context(|| format!("install {}", destination.display()))
}
fn validate_download_url(value: &str) -> Result<()> {
let url = Url::parse(value).with_context(|| format!("parse pet asset download URL {value}"))?;
if url.scheme() != "https" {
bail!("unsupported pet asset download URL scheme {}", url.scheme());
}
Ok(())
}
fn validate_cached_spritesheet(path: &Path) -> Result<()> {
let (width, height) =
image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?;
if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT {
bail!(
"invalid pet spritesheet dimensions for {}: expected {}x{}, got {}x{}",
path.display(),
catalog::SPRITESHEET_WIDTH,
catalog::SPRITESHEET_HEIGHT,
width,
height
);
}
Ok(())
}
#[cfg(test)]
pub(crate) fn write_test_pack(codex_home: &Path) {
let assets_dir = pack_dir(codex_home).join("assets");
fs::create_dir_all(&assets_dir).unwrap();
for pet in catalog::BUILTIN_PETS {
let path = assets_dir.join(pet.spritesheet_file);
catalog::write_test_spritesheet(&path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn builtin_pet_url_uses_public_cdn_path() {
let pet = catalog::builtin_pet("dewey").unwrap();
let url = builtin_pet_url(pet).unwrap();
assert_eq!(
url,
"https://persistent.oaistatic.com/codex/pets/v1/dewey-spritesheet-v4.webp"
);
}
#[test]
fn write_test_pack_installs_all_builtins() {
let dir = tempfile::tempdir().unwrap();
write_test_pack(dir.path());
for pet in catalog::BUILTIN_PETS {
let path = builtin_spritesheet_path(dir.path(), pet.spritesheet_file);
assert!(path.is_file());
validate_cached_spritesheet(&path).unwrap();
}
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Built-in pet catalog ported from the Codex App avatar catalog.
pub(super) const DEFAULT_FRAME_WIDTH: u32 = 192;
pub(super) const DEFAULT_FRAME_HEIGHT: u32 = 208;
pub(super) const DEFAULT_FRAME_COLUMNS: u32 = 8;
pub(super) const DEFAULT_FRAME_ROWS: u32 = 9;
pub(super) const SPRITESHEET_WIDTH: u32 = DEFAULT_FRAME_WIDTH * DEFAULT_FRAME_COLUMNS;
pub(super) const SPRITESHEET_HEIGHT: u32 = DEFAULT_FRAME_HEIGHT * DEFAULT_FRAME_ROWS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct BuiltinPet {
pub(super) id: &'static str,
pub(super) display_name: &'static str,
pub(super) description: &'static str,
pub(super) spritesheet_file: &'static str,
}
pub(super) const BUILTIN_PETS: &[BuiltinPet] = &[
BuiltinPet {
id: "codex",
display_name: "Codex",
description: "The original Codex companion",
spritesheet_file: "codex-spritesheet-v4.webp",
},
BuiltinPet {
id: "dewey",
display_name: "Dewey",
description: "A tidy duck for calm workspace days",
spritesheet_file: "dewey-spritesheet-v4.webp",
},
BuiltinPet {
id: "fireball",
display_name: "Fireball",
description: "Hot path energy for fast iteration",
spritesheet_file: "fireball-spritesheet-v4.webp",
},
BuiltinPet {
id: "rocky",
display_name: "Rocky",
description: "A steady rock when the diff gets large",
spritesheet_file: "rocky-spritesheet-v4.webp",
},
BuiltinPet {
id: "seedy",
display_name: "Seedy",
description: "Small green shoots for new ideas",
spritesheet_file: "seedy-spritesheet-v4.webp",
},
BuiltinPet {
id: "stacky",
display_name: "Stacky",
description: "A balanced stack for deep work",
spritesheet_file: "stacky-spritesheet-v4.webp",
},
BuiltinPet {
id: "bsod",
display_name: "BSOD",
description: "A tiny blue-screen gremlin",
spritesheet_file: "bsod-spritesheet-v4.webp",
},
BuiltinPet {
id: "null-signal",
display_name: "Null Signal",
description: "Quiet signal from the void",
spritesheet_file: "null-signal-spritesheet-v4.webp",
},
];
pub(super) fn builtin_pet(id: &str) -> Option<BuiltinPet> {
BUILTIN_PETS.iter().copied().find(|pet| pet.id == id)
}
#[cfg(test)]
pub(super) fn write_test_spritesheet(path: &std::path::Path) {
let image = image::RgbaImage::new(SPRITESHEET_WIDTH, SPRITESHEET_HEIGHT);
image.save(path).unwrap();
}
+116
View File
@@ -0,0 +1,116 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use image::GenericImageView;
use super::model::Pet;
pub(super) fn prepare_png_frames(pet: &Pet, frame_dir: &Path) -> Result<Vec<PathBuf>> {
fs::create_dir_all(frame_dir).with_context(|| format!("create {}", frame_dir.display()))?;
let expected: Vec<PathBuf> = (0..pet.frame_count())
.map(|index| frame_dir.join(format!("frame_{index:03}.png")))
.collect();
let complete = expected.iter().all(|path| path.exists());
if !complete {
for stale in glob_frame_files(frame_dir)? {
let _ = fs::remove_file(stale);
}
let spritesheet = image::open(&pet.spritesheet_path)
.with_context(|| format!("read {}", pet.spritesheet_path.display()))?;
for row in 0..pet.rows {
for column in 0..pet.columns {
let index = row
.checked_mul(pet.columns)
.and_then(|row_offset| row_offset.checked_add(column))
.context("pet frame index overflow")?;
let index = usize::try_from(index).context("pet frame index does not fit usize")?;
let path = expected
.get(index)
.context("pet frame index exceeds expected frame count")?;
let x = column
.checked_mul(pet.frame_width)
.context("pet frame x offset overflow")?;
let y = row
.checked_mul(pet.frame_height)
.context("pet frame y offset overflow")?;
let frame = spritesheet.try_view(x, y, pet.frame_width, pet.frame_height)?;
frame
.to_image()
.save_with_format(path, image::ImageFormat::Png)
.with_context(|| format!("write {}", path.display()))?;
}
}
}
Ok(expected)
}
fn glob_frame_files(frame_dir: &Path) -> Result<Vec<PathBuf>> {
if !frame_dir.exists() {
return Ok(Vec::new());
}
let mut paths = Vec::new();
for entry in fs::read_dir(frame_dir).with_context(|| format!("read {}", frame_dir.display()))? {
let path = entry?.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("frame_") && name.ends_with(".png"))
{
paths.push(path);
}
}
Ok(paths)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use image::ImageBuffer;
use image::Rgba;
use super::*;
#[test]
fn prepare_png_frames_slices_spritesheet_without_external_command() {
let dir = tempfile::tempdir().unwrap();
let spritesheet_path = dir.path().join("spritesheet.png");
let spritesheet: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::from_fn(2, 1, |x, _| {
if x == 0 {
Rgba([255, 0, 0, 255])
} else {
Rgba([0, 255, 0, 255])
}
});
spritesheet.save(&spritesheet_path).unwrap();
let frames = prepare_png_frames(
&Pet {
id: "tiny".to_string(),
display_name: "Tiny".to_string(),
description: String::new(),
spritesheet_path,
frame_width: 1,
frame_height: 1,
columns: 2,
rows: 1,
frame_count: 2,
animations: HashMap::new(),
},
&dir.path().join("frames"),
)
.unwrap();
assert_eq!(frames.len(), 2);
assert!(frames[0].exists());
assert!(frames[1].exists());
}
}
+591
View File
@@ -0,0 +1,591 @@
use std::env;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use base64::Engine as _;
use base64::engine::general_purpose;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use image::imageops::FilterType;
use super::sixel;
const ESC: &str = "\x1b";
const ST: &str = "\x1b\\";
const KITTY_CHUNK_SIZE: usize = 4096;
const SIXEL_CACHE_VERSION: &str = "v2";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
KittyLocalFile,
Sixel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetImageSupport {
Supported(ImageProtocol),
Unsupported(PetImageUnsupportedReason),
}
impl PetImageSupport {
pub(crate) fn protocol(self) -> Option<ImageProtocol> {
match self {
Self::Supported(protocol) => Some(protocol),
Self::Unsupported(_) => None,
}
}
pub(crate) fn unsupported_message(self) -> Option<&'static str> {
match self {
Self::Supported(_) => None,
Self::Unsupported(reason) => Some(reason.message()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PetImageUnsupportedReason {
Tmux,
Zellij,
Terminal,
}
impl PetImageUnsupportedReason {
fn message(self) -> &'static str {
match self {
Self::Tmux => {
"Pets are disabled in tmux. Terminal images don’t stay pane-local in tmux and can corrupt scrollback or move between panes. Run Codex outside tmux to use pets."
}
Self::Zellij => {
"Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets."
}
Self::Terminal => {
"Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux."
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolSelection {
Auto,
Kitty,
Sixel,
}
impl ProtocolSelection {
pub(crate) fn resolve(self) -> PetImageSupport {
match self {
Self::Kitty => PetImageSupport::Supported(ImageProtocol::Kitty),
Self::Sixel => PetImageSupport::Supported(ImageProtocol::Sixel),
Self::Auto => detect_pet_image_support(),
}
}
}
impl FromStr for ProtocolSelection {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"auto" => Ok(Self::Auto),
"kitty" => Ok(Self::Kitty),
"sixel" => Ok(Self::Sixel),
other => bail!("unknown protocol {other}; expected auto, kitty, or sixel"),
}
}
}
pub(crate) fn detect_pet_image_support() -> PetImageSupport {
if env::var_os("TMUX").is_some() || env::var_os("TMUX_PANE").is_some() {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux);
}
if env::var_os("ZELLIJ").is_some()
|| env::var_os("ZELLIJ_SESSION_NAME").is_some()
|| env::var_os("ZELLIJ_VERSION").is_some()
{
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij);
}
if env::var_os("KITTY_WINDOW_ID").is_some() {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
if env::var_os("WEZTERM_EXECUTABLE").is_some() || env::var_os("WEZTERM_VERSION").is_some() {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
pet_image_support_for_terminal(&terminal_info())
}
fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport {
match info.multiplexer {
Some(Multiplexer::Tmux { .. }) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux);
}
Some(Multiplexer::Zellij {}) => {
return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij);
}
None => {}
}
if supports_iterm2_kitty_graphics(info) {
return PetImageSupport::Supported(ImageProtocol::KittyLocalFile);
}
if supports_kitty_graphics(info) {
return PetImageSupport::Supported(ImageProtocol::Kitty);
}
if supports_sixel(info) {
return PetImageSupport::Supported(ImageProtocol::Sixel);
}
PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal)
}
fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool {
matches!(info.name, TerminalName::Iterm2)
|| terminal_field_contains(info.term_program.as_deref(), "iterm")
}
fn supports_kitty_graphics(info: &TerminalInfo) -> bool {
matches!(
info.name,
TerminalName::Ghostty | TerminalName::Kitty | TerminalName::WezTerm
) || terminal_field_contains(info.term.as_deref(), "kitty")
|| terminal_field_contains(info.term.as_deref(), "ghostty")
|| terminal_field_contains(info.term.as_deref(), "wezterm")
|| terminal_field_contains(info.term_program.as_deref(), "kitty")
|| terminal_field_contains(info.term_program.as_deref(), "ghostty")
|| terminal_field_contains(info.term_program.as_deref(), "wezterm")
}
fn supports_sixel(info: &TerminalInfo) -> bool {
matches!(info.name, TerminalName::WindowsTerminal)
|| terminal_field_contains(info.term.as_deref(), "sixel")
|| terminal_field_contains(info.term.as_deref(), "mlterm")
|| terminal_field_contains(info.term.as_deref(), "foot")
}
fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool {
value.is_some_and(|value| value.to_ascii_lowercase().contains(needle))
}
pub fn kitty_delete_image(image_id: u32) -> String {
wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}"))
}
pub fn kitty_transmit_png_with_id(
path: &Path,
columns: u16,
rows: u16,
image_id: Option<u32>,
) -> Result<String> {
let png = fs::read(path).with_context(|| format!("read {}", path.display()))?;
let payload = general_purpose::STANDARD.encode(png);
let chunks = payload
.as_bytes()
.chunks(KITTY_CHUNK_SIZE)
.collect::<Vec<_>>();
let mut command = String::new();
for (index, chunk) in chunks.iter().enumerate() {
let chunk = std::str::from_utf8(chunk).context("base64 payload is not valid UTF-8")?;
let has_more = index + 1 < chunks.len();
let more_flag = u8::from(has_more);
if index == 0 {
let image_id = kitty_image_id_arg(image_id);
command.push_str(&format!(
"{ESC}_Ga=T,t=d,f=100,c={columns},r={rows},q=2{image_id},m={more_flag};{chunk}{ST}",
));
} else {
command.push_str(&format!("{ESC}_Gm={more_flag};{chunk}{ST}"));
}
}
Ok(wrap_for_tmux_if_needed(&command))
}
pub fn kitty_transmit_png_file_with_id(
path: &Path,
columns: u16,
rows: u16,
image_id: Option<u32>,
) -> Result<String> {
let path = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes());
let image_id = kitty_image_id_arg(image_id);
let command = format!("{ESC}_Ga=T,t=f,f=100,c={columns},r={rows},q=2{image_id};{payload}{ST}");
Ok(wrap_for_tmux_if_needed(&command))
}
fn kitty_image_id_arg(image_id: Option<u32>) -> String {
image_id
.map(|image_id| format!(",i={image_id}"))
.unwrap_or_default()
}
fn wrap_for_tmux_if_needed(command: &str) -> String {
if env::var_os("TMUX").is_none() {
return command.to_string();
}
let escaped = command.replace(ESC, "\x1b\x1b");
format!("{ESC}Ptmux;{escaped}{ST}")
}
pub fn sixel_frame(frame_path: &Path, cache_dir: &Path, height_px: u16) -> Result<PathBuf> {
fs::create_dir_all(cache_dir).with_context(|| format!("create {}", cache_dir.display()))?;
let stem = frame_path
.file_stem()
.and_then(|stem| stem.to_str())
.context("frame path has no valid file stem")?;
let path = cache_dir.join(format!("{stem}_h{height_px}_{SIXEL_CACHE_VERSION}.six"));
if path.exists() {
return Ok(path);
}
let frame =
image::open(frame_path).with_context(|| format!("read {}", frame_path.display()))?;
let height = u32::from(height_px).max(1);
let width = ((u64::from(frame.width()) * u64::from(height)) / u64::from(frame.height()))
.try_into()
.unwrap_or(u32::MAX)
.max(1);
let rgba = frame.resize(width, height, FilterType::Lanczos3).to_rgba8();
let (width, height) = rgba.dimensions();
let sixel = sixel::encode_rgba(&rgba.into_raw(), width, height)?;
fs::write(&path, sixel).with_context(|| format!("write {}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::*;
struct EnvVarGuard {
name: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn new(name: &'static str, value: Option<&str>) -> Self {
let previous = env::var_os(name);
match value {
Some(value) => unsafe { env::set_var(name, value) },
None => unsafe { env::remove_var(name) },
}
Self { name, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { env::set_var(self.name, value) },
None => unsafe { env::remove_var(self.name) },
}
}
}
#[test]
#[serial]
fn kitty_png_transmission_encodes_inline_data() {
let _guard = EnvVarGuard::new("TMUX", /*value*/ None);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("frame.png");
fs::write(&path, b"png").unwrap();
let command = kitty_transmit_png_with_id(
&path, /*columns*/ 4, /*rows*/ 3, /*image_id*/ None,
)
.unwrap();
assert!(command.starts_with("\x1b_Ga=T,t=d,f=100,c=4,r=3,q=2,m=0;"));
assert!(command.contains("cG5n"));
assert!(command.ends_with("\x1b\\"));
}
#[test]
#[serial]
fn tmux_passthrough_wraps_and_escapes_control_sequence() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
wrap_for_tmux_if_needed("\x1b_Gx;\x1b\\"),
"\x1bPtmux;\x1b\x1b_Gx;\x1b\x1b\\\x1b\\"
);
}
#[test]
fn parses_protocol_selection() {
assert_eq!(
"auto".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Auto
);
assert_eq!(
"kitty".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Kitty
);
assert_eq!(
"sixel".parse::<ProtocolSelection>().unwrap(),
ProtocolSelection::Sixel
);
}
#[test]
#[serial]
fn auto_protocol_is_disabled_inside_tmux() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
ProtocolSelection::Auto.resolve(),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux)
);
}
#[test]
#[serial]
fn explicit_protocol_still_resolves_inside_tmux() {
let _guard = EnvVarGuard::new("TMUX", Some("session"));
assert_eq!(
ProtocolSelection::Kitty.resolve(),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
assert_eq!(
ProtocolSelection::Sixel.resolve(),
PetImageSupport::Supported(ImageProtocol::Sixel)
);
}
#[test]
fn pet_image_support_prefers_multiplexer_safety() {
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Ghostty,
Some(Multiplexer::Tmux { version: None }),
Some("Ghostty"),
/*term*/ None,
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux)
);
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Kitty,
Some(Multiplexer::Zellij {}),
Some("kitty"),
/*term*/ None,
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij)
);
}
#[test]
fn pet_image_support_detects_iterm2_kitty_file_graphics() {
for info in [
terminal_info_for_test(
TerminalName::Iterm2,
/*multiplexer*/ None,
Some("iTerm.app"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("iTerm.app"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::KittyLocalFile)
);
}
}
#[test]
fn pet_image_support_detects_kitty_graphics_terminals() {
for info in [
terminal_info_for_test(
TerminalName::Ghostty,
/*multiplexer*/ None,
Some("Ghostty"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Kitty,
/*multiplexer*/ None,
Some("kitty"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::WezTerm,
/*multiplexer*/ None,
Some("WezTerm"),
/*term*/ None,
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-kitty"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("wezterm"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
Some("WezTerm"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
}
}
#[test]
fn pet_image_support_detects_sixel_terminals() {
for info in [
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-sixel"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("foot"),
),
terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("mlterm"),
),
terminal_info_for_test(
TerminalName::WindowsTerminal,
/*multiplexer*/ None,
Some("WindowsTerminal"),
Some("xterm-256color"),
),
] {
assert_eq!(
pet_image_support_for_terminal(&info),
PetImageSupport::Supported(ImageProtocol::Sixel)
);
}
}
#[test]
#[serial]
fn wezterm_env_uses_kitty_graphics_for_ambient_pets() {
let _tmux = EnvVarGuard::new("TMUX", /*value*/ None);
let _tmux_pane = EnvVarGuard::new("TMUX_PANE", /*value*/ None);
let _zellij = EnvVarGuard::new("ZELLIJ", /*value*/ None);
let _zellij_session = EnvVarGuard::new("ZELLIJ_SESSION_NAME", /*value*/ None);
let _zellij_version = EnvVarGuard::new("ZELLIJ_VERSION", /*value*/ None);
let _kitty = EnvVarGuard::new("KITTY_WINDOW_ID", /*value*/ None);
let _wezterm = EnvVarGuard::new("WEZTERM_VERSION", Some("20240203"));
let _wezterm_executable = EnvVarGuard::new("WEZTERM_EXECUTABLE", /*value*/ None);
assert_eq!(
detect_pet_image_support(),
PetImageSupport::Supported(ImageProtocol::Kitty)
);
}
#[test]
fn pet_image_support_rejects_unknown_terminals() {
assert_eq!(
pet_image_support_for_terminal(&terminal_info_for_test(
TerminalName::Unknown,
/*multiplexer*/ None,
/*term_program*/ None,
Some("xterm-256color"),
)),
PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal)
);
}
fn terminal_info_for_test(
name: TerminalName,
multiplexer: Option<Multiplexer>,
term_program: Option<&str>,
term: Option<&str>,
) -> TerminalInfo {
TerminalInfo {
name,
term_program: term_program.map(str::to_string),
version: /*version*/ None,
term: term.map(str::to_string),
multiplexer,
}
}
#[test]
fn sixel_frame_encodes_without_external_crate() {
let dir = tempfile::tempdir().unwrap();
let frame_path = dir.path().join("frame.png");
let rgba = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255]));
rgba.save(&frame_path).unwrap();
let sixel_path =
sixel_frame(&frame_path, &dir.path().join("sixel"), /*height_px*/ 1).unwrap();
let sixel = fs::read_to_string(sixel_path).unwrap();
assert!(sixel.starts_with("\x1bP9;1;0q\"1;1;1;1"));
assert!(sixel.contains("#224;2;100;0;0"));
assert!(sixel.contains("#224@"));
assert!(sixel.ends_with("\x1b\\"));
}
#[test]
#[serial]
fn kitty_file_png_transmission_encodes_local_file_reference() {
let _guard = EnvVarGuard::new("TMUX", /*value*/ None);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("frame.png");
fs::write(&path, b"png").unwrap();
let command = kitty_transmit_png_file_with_id(
&path,
/*columns*/ 4,
/*rows*/ 3,
/*image_id*/ Some(7),
)
.unwrap();
let path = path.canonicalize().unwrap();
let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes());
assert_eq!(
command,
format!("\x1b_Ga=T,t=f,f=100,c=4,r=3,q=2,i=7;{payload}\x1b\\")
);
}
}
+468
View File
@@ -0,0 +1,468 @@
//! Ambient terminal pets configured from the /pets slash command.
//!
//! The TUI treats built-in and custom pets differently on purpose:
//! built-in pets are versioned application assets fetched on demand into a
//! managed CODEX_HOME cache, while custom pets remain entirely user-owned data
//! under `$CODEX_HOME/pets/<pet-id>/pet.json` or legacy avatar directories.
//!
//! This module owns the TUI-facing contracts around that split:
//! resolving a selected pet id, preparing frames for terminal image protocols,
//! rendering the ambient sprite and picker preview, and preserving enough
//! metadata for `/pets` to behave like a first-class configuration surface.
//! It does not own config persistence or popup orchestration; callers must
//! ensure a built-in asset exists before loading it and must persist the final
//! selection only after the load succeeds.
use std::io::Write;
mod ambient;
mod asset_pack;
mod catalog;
mod frames;
mod image_protocol;
mod model;
mod picker;
mod preview;
mod sixel;
use anyhow::Context;
use anyhow::Result;
pub(crate) use ambient::AmbientPet;
pub(crate) use ambient::AmbientPetDraw;
pub(crate) use ambient::PetNotificationKind;
#[cfg(test)]
pub(crate) use ambient::test_ambient_pet;
pub(crate) use asset_pack::builtin_spritesheet_path;
#[cfg(test)]
pub(crate) use asset_pack::write_test_pack;
#[cfg(test)]
pub(crate) use image_protocol::ImageProtocol;
pub(crate) use image_protocol::PetImageSupport;
#[cfg(test)]
pub(crate) use image_protocol::PetImageUnsupportedReason;
#[cfg(not(test))]
pub(crate) use image_protocol::detect_pet_image_support;
pub(crate) use picker::PET_PICKER_VIEW_ID;
pub(crate) use picker::build_pet_picker_params;
pub(crate) use preview::PetPickerPreviewState;
pub(crate) const DEFAULT_PET_ID: &str = "codex";
pub(crate) const DISABLED_PET_ID: &str = "disabled";
/// Ensure that a selected built-in pet has a locally cached spritesheet.
///
/// Custom pets are intentionally a no-op here because their source of truth is
/// already local. Callers should invoke this before loading a built-in pet for
/// preview or selection; skipping it would make first-use preview and
/// persistence failures depend on deeper image-loading errors instead of the
/// asset-fetch boundary.
pub(crate) fn ensure_builtin_pack_for_pet(
pet_id: &str,
codex_home: &std::path::Path,
) -> Result<()> {
if let Some(pet) = catalog::builtin_pet(pet_id) {
asset_pack::ensure_builtin_pet(codex_home, pet)?;
}
Ok(())
}
#[derive(Debug)]
pub(crate) enum PetImageRenderError {
Terminal(std::io::Error),
Asset(anyhow::Error),
}
impl std::fmt::Display for PetImageRenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Terminal(err) => write!(f, "terminal image write failed: {err}"),
Self::Asset(err) => write!(f, "pet image asset unavailable: {err}"),
}
}
}
impl std::error::Error for PetImageRenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Terminal(err) => Some(err),
Self::Asset(err) => Some(err.as_ref()),
}
}
}
impl From<std::io::Error> for PetImageRenderError {
fn from(err: std::io::Error) -> Self {
Self::Terminal(err)
}
}
pub(crate) fn render_ambient_pet_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
render_pet_image(writer, state, /*image_id*/ 0xC0DE, request)
}
pub(crate) fn render_pet_picker_preview_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
render_pet_image(writer, state, /*image_id*/ 0xC0DF, request)
}
#[derive(Debug, Default)]
pub(crate) struct PetImageRenderState {
last_sixel_clear_area: Option<SixelClearArea>,
last_protocol: Option<image_protocol::ImageProtocol>,
}
fn render_pet_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
image_id: u32,
request: Option<AmbientPetDraw>,
) -> std::result::Result<(), PetImageRenderError> {
use crossterm::cursor::MoveTo;
use crossterm::cursor::RestorePosition;
use crossterm::cursor::SavePosition;
use crossterm::queue;
use image_protocol::ImageProtocol;
let Some(request) = request else {
if state.last_protocol.take().is_some_and(is_kitty_protocol) {
write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?;
}
if let Some(area) = state.last_sixel_clear_area.take() {
queue!(writer, SavePosition)?;
clear_sixel_area(writer, area)?;
queue!(writer, RestorePosition)?;
}
writer.flush()?;
return Ok(());
};
if state.last_protocol.take().is_some_and(is_kitty_protocol)
|| is_kitty_protocol(request.protocol)
{
write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?;
}
state.last_protocol = Some(request.protocol);
let payload = match request.protocol {
ImageProtocol::Kitty => AmbientPetPayload::Text(
image_protocol::kitty_transmit_png_with_id(
&request.frame,
request.columns,
request.rows,
Some(image_id),
)
.map_err(PetImageRenderError::Asset)?,
),
ImageProtocol::KittyLocalFile => AmbientPetPayload::Text(
image_protocol::kitty_transmit_png_file_with_id(
&request.frame,
request.columns,
request.rows,
Some(image_id),
)
.map_err(PetImageRenderError::Asset)?,
),
ImageProtocol::Sixel => {
let path =
image_protocol::sixel_frame(&request.frame, &request.sixel_dir, request.height_px)
.map_err(PetImageRenderError::Asset)?;
let sixel = std::fs::read(&path)
.with_context(|| format!("read {}", path.display()))
.map_err(PetImageRenderError::Asset)?;
AmbientPetPayload::Bytes(sixel)
}
};
queue!(writer, SavePosition)?;
let current_sixel_clear_area = if matches!(request.protocol, ImageProtocol::Sixel) {
Some(SixelClearArea::from(&request))
} else {
None
};
if let Some(previous_area) = state.last_sixel_clear_area.take()
&& Some(previous_area) != current_sixel_clear_area
{
clear_sixel_area(writer, previous_area)?;
}
if let Some(area) = current_sixel_clear_area {
clear_sixel_area(writer, area)?;
state.last_sixel_clear_area = Some(area);
}
queue!(writer, MoveTo(request.x, request.y))?;
match payload {
AmbientPetPayload::Text(payload) => write!(writer, "{payload}")?,
AmbientPetPayload::Bytes(payload) => writer.write_all(&payload)?,
}
queue!(writer, RestorePosition)?;
writer.flush()?;
Ok(())
}
enum AmbientPetPayload {
Text(String),
Bytes(Vec<u8>),
}
fn is_kitty_protocol(protocol: image_protocol::ImageProtocol) -> bool {
matches!(
protocol,
image_protocol::ImageProtocol::Kitty | image_protocol::ImageProtocol::KittyLocalFile
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SixelClearArea {
x: u16,
clear_top_y: u16,
clear_bottom_y: u16,
columns: u16,
}
impl From<&AmbientPetDraw> for SixelClearArea {
fn from(request: &AmbientPetDraw) -> Self {
Self {
x: request.x,
clear_top_y: request.clear_top_y,
clear_bottom_y: request.y.saturating_add(request.rows),
columns: request.columns,
}
}
}
fn clear_sixel_area(writer: &mut impl Write, area: SixelClearArea) -> std::io::Result<()> {
use crossterm::cursor::MoveTo;
use crossterm::queue;
let blank = " ".repeat(area.columns.into());
for row in area.clear_top_y..area.clear_bottom_y {
queue!(writer, MoveTo(area.x, row))?;
write!(writer, "{blank}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use std::io;
use std::path::PathBuf;
use super::image_protocol::ImageProtocol;
use super::*;
#[test]
fn ambient_pet_image_restores_cursor_after_drawing() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
let save = output.find("\x1b7").expect("saves cursor position");
let move_to = output.find("\x1b[4;3H").expect("moves to pet position");
let image = output.find("cG5n").expect("writes image payload");
let restore = output.find("\x1b8").expect("restores cursor position");
assert!(save < move_to);
assert!(move_to < image);
assert!(image < restore);
}
#[test]
fn kitty_pet_image_clear_deletes_without_moving_cursor() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
output.clear();
render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("Ga=d,d=I,i=49374,q=2;"));
assert!(!output.contains("\x1b7"));
assert!(!output.contains("\x1b["));
assert!(!output.contains("\x1b8"));
}
#[test]
fn kitty_local_file_pet_image_uses_file_reference_without_inline_payload() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::KittyLocalFile,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("a=d,d=I,i=49374,q=2;"));
assert!(output.contains("\x1b[4;3H"));
assert!(output.contains("a=T,t=f,f=100,c=4,r=2,q=2,i=49374;"));
assert!(!output.contains("cG5n"));
assert!(output.contains("\x1b8"));
}
#[test]
fn sixel_pet_image_clears_cell_area_before_redrawing() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let sixel_dir = dir.path().join("sixel");
std::fs::create_dir(&sixel_dir).unwrap();
let sixel_frame = sixel_dir.join("frame_h75_v2.six");
std::fs::write(&sixel_frame, b"fake-sixel").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Sixel,
x: 2,
y: 3,
clear_top_y: 1,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir,
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H \x1b[4;3H"));
assert!(output.contains("fake-sixel"));
assert!(output.contains("\x1b8"));
}
#[test]
fn sixel_pet_image_clear_erases_last_drawn_area() {
let dir = tempfile::tempdir().unwrap();
let frame = dir.path().join("frame.png");
std::fs::write(&frame, b"png").unwrap();
let sixel_dir = dir.path().join("sixel");
std::fs::create_dir(&sixel_dir).unwrap();
let sixel_frame = sixel_dir.join("frame_h75_v2.six");
std::fs::write(&sixel_frame, b"fake-sixel").unwrap();
let request = AmbientPetDraw {
frame,
protocol: ImageProtocol::Sixel,
x: 2,
y: 3,
clear_top_y: 1,
columns: 4,
rows: 2,
height_px: 75,
sixel_dir,
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap();
output.clear();
render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(!output.contains("Ga=d,d=I,i=49374,q=2;"));
assert!(output.contains("\x1b7"));
assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H "));
assert!(output.contains("\x1b8"));
assert!(!output.contains("fake-sixel"));
}
#[test]
fn missing_frame_is_an_asset_error() {
let dir = tempfile::tempdir().unwrap();
let request = AmbientPetDraw {
frame: dir.path().join("missing.png"),
protocol: ImageProtocol::Kitty,
x: 2,
y: 3,
clear_top_y: 3,
columns: 4,
rows: 5,
height_px: 75,
sixel_dir: PathBuf::new(),
};
let mut output = Vec::new();
let mut state = PetImageRenderState::default();
let err = render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap_err();
assert!(matches!(err, PetImageRenderError::Asset(_)));
assert!(err.source().is_some());
}
#[test]
fn writer_failure_is_a_terminal_error() {
struct FailingWriter;
impl io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"test writer failed",
))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let mut writer = FailingWriter;
let mut state = PetImageRenderState {
last_protocol: Some(ImageProtocol::Kitty),
..Default::default()
};
let err = render_ambient_pet_image(&mut writer, &mut state, /*request*/ None).unwrap_err();
assert!(matches!(err, PetImageRenderError::Terminal(_)));
assert!(err.source().is_some());
}
}
File diff suppressed because it is too large Load Diff

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