mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Merge 1915975876 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+16
-3
@@ -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]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+7
@@ -12616,6 +12616,13 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"remotePluginId": {
|
||||
"description": "Backend remote plugin identifier when available.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"shareContext": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+7
@@ -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" },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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": {{
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "guardian",
|
||||
crate_name = "codex_guardian",
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› /pet "
|
||||
" "
|
||||
" "
|
||||
" /pets choose or hide the terminal pet "
|
||||
@@ -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(¬ification_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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/exec_flow.rs
|
||||
expression: contents
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/permissions.rs
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/permissions.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable full access?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable memories?
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable subagents?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Personality
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/plan_mode.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/plan_mode.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Microphone
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+25
@@ -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
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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())
|
||||
---
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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\\")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user