feat(pro): add observer runtime and TUI controls

This commit is contained in:
James Peter
2025-09-23 07:22:58 +10:00
parent 6c8d20d7d9
commit 3f42a1eb79
21 changed files with 1988 additions and 97 deletions
+343 -6
View File
@@ -10,8 +10,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use std::sync::Weak;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use async_channel::Receiver;
use async_channel::Sender;
@@ -43,6 +44,9 @@ use crate::CodexAuth;
use crate::acp::AcpFileSystem;
use crate::agent_tool::AgentStatusUpdatePayload;
use crate::protocol::ApprovedCommandMatchKind;
use crate::protocol::ProEvent;
use crate::protocol::ProPhase;
use crate::protocol::ProStats;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::models::WebSearchAction;
@@ -56,6 +60,7 @@ use self::compact::collect_user_messages;
/// Initial submission ID for session configuration
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
pub(crate) const PRO_SUBMISSION_ID: &str = "pro";
const HOOK_OUTPUT_LIMIT: usize = 2048;
const PENDING_ONLY_SENTINEL: &str = "__codex_pending_only__";
@@ -841,6 +846,16 @@ pub(crate) struct Session {
hook_guard: AtomicBool,
github: Arc<RwLock<crate::config_types::GithubConfig>>,
validation: Arc<RwLock<crate::config_types::ValidationConfig>>,
pro_enabled: AtomicBool,
pro_supervisor: Mutex<Option<crate::pro_supervisor::ProSupervisorHandle>>,
pro_observer_history: Mutex<ConversationHistory>,
pro_observer_log: Mutex<Vec<ResponseItem>>,
pro_autonomous_enabled: AtomicBool,
pro_observer_running: AtomicBool,
pro_observer_trigger_after_current: AtomicBool,
pro_observer_last_trigger_at: Mutex<Option<Instant>>,
pro_observer_activity_since_last: AtomicU64,
self_handle: Weak<Session>,
}
struct HookGuard<'a> {
@@ -914,6 +929,188 @@ impl Session {
self.client_tools.as_ref()
}
fn upgrade_self(&self) -> Option<Arc<Self>> {
self.self_handle.upgrade()
}
pub(crate) fn get_user_shell(&self) -> shell::Shell {
self.user_shell.clone()
}
pub(crate) fn get_history_contents(&self) -> Vec<ResponseItem> {
self.state.lock_unchecked().history.contents()
}
pub(crate) fn model_client(&self) -> &ModelClient {
&self.client
}
pub(crate) fn pro_observer_history(&self) -> &Mutex<ConversationHistory> {
&self.pro_observer_history
}
pub(crate) fn pro_observer_log(&self) -> &Mutex<Vec<ResponseItem>> {
&self.pro_observer_log
}
pub(crate) fn pro_is_enabled(&self) -> bool {
self.pro_enabled.load(Ordering::Relaxed)
}
pub(crate) fn pro_autonomous_enabled(&self) -> bool {
self.pro_autonomous_enabled.load(Ordering::Relaxed)
}
pub(crate) async fn emit_pro_event(&self, sub_id: &str, event: ProEvent) {
let msg = EventMsg::Pro(event);
let event = self.make_event(sub_id, msg);
let _ = self.tx_event.send(event).await;
}
pub(crate) async fn emit_pro_status_snapshot(&self, sub_id: &str) {
self
.emit_pro_event(
sub_id,
ProEvent::Status {
phase: ProPhase::Idle,
stats: ProStats::default(),
},
)
.await;
}
pub(crate) fn stop_pro_supervisor(&self) {
let mut guard = self.pro_supervisor.lock_unchecked();
if let Some(handle) = guard.take() {
handle.abort();
}
}
pub(crate) fn ensure_pro_supervisor(self: &Arc<Self>) {
let mut guard = self.pro_supervisor.lock_unchecked();
if guard.is_none() {
let handle = crate::pro_supervisor::spawn(Arc::clone(self));
*guard = Some(handle);
}
}
pub(crate) fn observer_mark_activity(&self) {
self.pro_observer_activity_since_last
.fetch_add(1, Ordering::Relaxed);
}
fn history_has_assistant_output(&self) -> bool {
let state = self.state.lock_unchecked();
Self::items_have_assistant_output(&state.history.contents())
}
fn items_have_assistant_output(items: &[ResponseItem]) -> bool {
items.iter().any(|item| match item {
ResponseItem::Message { role, content, .. } if role == "assistant" => content
.iter()
.any(|c| matches!(c, ContentItem::OutputText { text } if !text.trim().is_empty())),
ResponseItem::FunctionCall { .. } | ResponseItem::FunctionCallOutput { .. } => true,
_ => false,
})
}
pub(crate) fn observer_maybe_trigger(&self, sub_id: String, force: bool, reason: &'static str) {
if !self.pro_is_enabled() {
return;
}
if self.pro_observer_running.load(Ordering::Relaxed) {
if force {
self.pro_observer_trigger_after_current
.store(true, Ordering::Relaxed);
}
return;
}
let now = Instant::now();
let mut last_guard = self.pro_observer_last_trigger_at.lock_unchecked();
if !force {
let ready = last_guard
.map(|t| now.duration_since(t) >= Duration::from_secs(20))
.unwrap_or(true);
if !ready {
return;
}
}
*last_guard = Some(now);
drop(last_guard);
if !force {
let activity = self
.pro_observer_activity_since_last
.load(Ordering::Relaxed);
if activity < 4 {
return;
}
if !self.history_has_assistant_output() {
return;
}
}
self.pro_observer_activity_since_last
.store(0, Ordering::Relaxed);
self.pro_observer_running
.store(true, Ordering::Relaxed);
let Some(sess_arc) = self.upgrade_self() else {
self.pro_observer_running
.store(false, Ordering::Relaxed);
return;
};
tokio::spawn(async move {
crate::pro_observer::observe_now(Arc::clone(&sess_arc), sub_id.clone(), reason).await;
sess_arc
.pro_observer_running
.store(false, Ordering::Relaxed);
if sess_arc
.pro_observer_trigger_after_current
.swap(false, Ordering::Relaxed)
{
tokio::time::sleep(Duration::from_secs(1)).await;
sess_arc.observer_maybe_trigger(sub_id, true, "follow_up");
}
});
}
pub(crate) async fn submit_follow_up_user_message(&self, text: String) {
let items = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.clone(),
}],
}];
self.record_conversation_items(&items).await;
let input = vec![InputItem::Text { text: text.clone() }];
if let Err(items) = self.inject_input(input) {
if let Some(sess_arc) = self.upgrade_self() {
let turn_context = sess_arc.make_turn_context();
let submission_id = Uuid::new_v4().to_string();
let agent = AgentTask::spawn(Arc::clone(&sess_arc), turn_context, submission_id, items);
sess_arc.set_task(agent);
}
}
if let Some(sess_arc) = self.upgrade_self() {
sess_arc
.emit_pro_event(
PRO_SUBMISSION_ID,
ProEvent::DeveloperNote {
turn_id: "observer_followup".to_string(),
note: "Observer submitted follow-up".to_string(),
artifacts: Vec::new(),
},
)
.await;
}
}
pub(crate) fn mcp_connection_manager(&self) -> &McpConnectionManager {
&self.mcp_connection_manager
}
@@ -1295,6 +1492,10 @@ impl Session {
self.record_state_snapshot(items).await;
self.state.lock().unwrap().history.record_items(items);
if self.pro_is_enabled() && Self::items_have_assistant_output(items) {
self.observer_mark_activity();
}
}
/// Clean up old screenshots and system status messages from conversation history
@@ -1548,6 +1749,14 @@ impl Session {
let _ = self.tx_event.send(event).await;
}
}
if self.pro_is_enabled() {
self.observer_mark_activity();
let force = *exit_code != 0;
let reason = if force { "exec_failure" } else { "activity" };
let sub = Uuid::new_v4().to_string();
self.observer_maybe_trigger(sub, force, reason);
}
}
/// Runs the exec tool call and emits events for the begin and end of the
/// command even on error.
@@ -2183,6 +2392,9 @@ impl Session {
// KillOnDrop in exec.rs.
// (debug removed)
self.stop_pro_supervisor();
self.pro_enabled.store(false, Ordering::Relaxed);
self.pro_autonomous_enabled.store(false, Ordering::Relaxed);
}
/// Spawn the configured notifier (if any) with the given JSON payload as
@@ -2735,7 +2947,7 @@ async fn submission_loop(
tools_config.web_search_allowed_domains =
config.tools_web_search_allowed_domains.clone();
sess = Some(Arc::new(Session {
let mut new_session = Arc::new(Session {
id: session_id,
client,
tools_config,
@@ -2767,7 +2979,22 @@ async fn submission_loop(
hook_guard: AtomicBool::new(false),
github: Arc::new(RwLock::new(config.github.clone())),
validation: Arc::new(RwLock::new(config.validation.clone())),
}));
pro_enabled: AtomicBool::new(false),
pro_supervisor: Mutex::new(None),
pro_observer_history: Mutex::new(ConversationHistory::new()),
pro_observer_log: Mutex::new(Vec::new()),
pro_autonomous_enabled: AtomicBool::new(false),
pro_observer_running: AtomicBool::new(false),
pro_observer_trigger_after_current: AtomicBool::new(false),
pro_observer_last_trigger_at: Mutex::new(None),
pro_observer_activity_since_last: AtomicU64::new(0),
self_handle: Weak::new(),
});
let weak_handle = Arc::downgrade(&new_session);
if let Some(inner) = Arc::get_mut(&mut new_session) {
inner.self_handle = weak_handle;
}
sess = Some(new_session);
if let Some(sess_arc) = &sess {
if !config.always_allow_commands.is_empty() {
let mut st = sess_arc.state.lock().unwrap();
@@ -2846,7 +3073,7 @@ async fn submission_loop(
let tx_event_clone = tx_event.clone();
tokio::spawn(async move {
while let Some(payload) = agent_rx.recv().await {
let event = sess_for_agents.make_event(
let status_event = sess_for_agents.make_event(
"agent_status",
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: payload.agents.clone(),
@@ -2854,7 +3081,28 @@ async fn submission_loop(
task: payload.task.clone(),
}),
);
let _ = tx_event_clone.send(event).await;
let _ = tx_event_clone.send(status_event).await;
let mut spawned: u32 = 0;
let mut active: u32 = 0;
let mut completed: u32 = 0;
for agent in &payload.agents {
spawned = spawned.saturating_add(1);
match agent.status.as_str() {
"pending" | "running" => active = active.saturating_add(1),
"completed" => completed = completed.saturating_add(1),
_ => {}
}
}
let phase = if active > 0 { ProPhase::Background } else { ProPhase::Idle };
let pro_event = sess_for_agents.make_event(
PRO_SUBMISSION_ID,
EventMsg::Pro(ProEvent::Status {
phase,
stats: crate::protocol::ProStats { active, completed, spawned },
}),
);
let _ = tx_event_clone.send(pro_event).await;
}
});
agent_manager_initialized = true;
@@ -2982,6 +3230,90 @@ async fn submission_loop(
});
}
Op::Pro { action } => {
let Some(active_sess) = sess.as_ref() else {
send_no_session_event(sub.id).await;
continue;
};
let sess_ref = Arc::clone(active_sess);
use crate::protocol::ProAction;
let mut enable_change: Option<bool> = None;
let mut auto_change: Option<bool> = None;
match action {
ProAction::On => enable_change = Some(true),
ProAction::Off => enable_change = Some(false),
ProAction::Toggle => {
enable_change = Some(!sess_ref.pro_is_enabled());
}
ProAction::Status => {
sess_ref.emit_pro_status_snapshot(&sub.id).await;
}
ProAction::AutoOn => auto_change = Some(true),
ProAction::AutoOff => auto_change = Some(false),
ProAction::AutoToggle => {
auto_change = Some(!sess_ref.pro_autonomous_enabled());
}
ProAction::AutoStatus => {
let enabled = sess_ref.pro_autonomous_enabled();
let message = format!(
"Autonomous mode: {}",
if enabled { "enabled" } else { "disabled" }
);
sess_ref
.emit_pro_event(
&sub.id,
ProEvent::DeveloperNote {
turn_id: "auto_status".to_string(),
note: message,
artifacts: Vec::new(),
},
)
.await;
}
}
if let Some(enable) = enable_change {
sess_ref.pro_enabled.store(enable, Ordering::Relaxed);
if enable {
sess_ref.ensure_pro_supervisor();
sess_ref
.emit_pro_status_snapshot(PRO_SUBMISSION_ID)
.await;
} else {
sess_ref.stop_pro_supervisor();
}
sess_ref
.emit_pro_event(
&sub.id,
ProEvent::Toggled { enabled: enable },
)
.await;
}
if let Some(auto) = auto_change {
sess_ref
.pro_autonomous_enabled
.store(auto, Ordering::Relaxed);
let message = format!(
"Pro Autonomous {}",
if auto { "enabled" } else { "disabled" }
);
sess_ref
.emit_pro_event(
&sub.id,
ProEvent::DeveloperNote {
turn_id: "autonomous_toggle".to_string(),
note: message,
artifacts: Vec::new(),
},
)
.await;
}
}
Op::RunProjectCommand { name } => {
let sess = match sess.as_ref() {
Some(sess) => sess,
@@ -3371,6 +3703,11 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
}
sess.tx_event.send(event).await.ok();
if sess.pro_is_enabled() {
let sub = Uuid::new_v4().to_string();
sess.observer_maybe_trigger(sub, true, "task_complete");
}
if let Some(queued) = sess.pop_next_queued_user_input() {
let sess_clone = Arc::clone(&sess);
tokio::spawn(async move {
@@ -5366,7 +5703,7 @@ fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams
params
}
async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = ctx.call_id.clone();
+2
View File
@@ -72,6 +72,8 @@ pub mod plan_tool;
pub mod project_doc;
pub mod project_features;
mod rollout;
mod pro_supervisor;
mod pro_observer;
pub(crate) mod safety;
pub mod seatbelt;
pub mod shell;
+69
View File
@@ -81,6 +81,75 @@ pub enum ConfigShellToolType {
StreamableShell,
}
/// Tool allowing the Pro observer to post concise recommendations to the HUD.
pub(crate) fn create_pro_recommend_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"title".to_string(),
JsonSchema::String {
description: Some("Short label for the recommendation (<= 60 chars)".to_string()),
},
);
properties.insert(
"note".to_string(),
JsonSchema::String {
description: Some("Concise recommendation with actionable guidance".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "pro_recommend".to_string(),
description: "Post a focused recommendation to the Pro Mode HUD.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["title".to_string(), "note".to_string()]),
additional_properties: Some(false),
},
})
}
/// Tool enabling the observer to submit a follow-up user message in autonomous mode.
pub(crate) fn create_pro_submit_user_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"text".to_string(),
JsonSchema::String {
description: Some("User message text to submit to the core conversation".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "pro_submit_user".to_string(),
description: "Submit a follow-up user message when autonomous mode is enabled.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["text".to_string()]),
additional_properties: Some(false),
},
})
}
/// Tool permitting the observer to inject high-priority developer guidance into the core agent.
pub(crate) fn create_assist_core_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"instructions".to_string(),
JsonSchema::String {
description: Some("Developer instructions to inject into the core agent".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "assist_core".to_string(),
description: "Inject important developer instructions into the core agent.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["instructions".to_string()]),
additional_properties: Some(false),
},
})
}
#[derive(Debug, Clone)]
pub(crate) struct ToolsConfig {
pub shell_type: ConfigShellToolType,
+480
View File
@@ -0,0 +1,480 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use futures::StreamExt;
use tokio::time::sleep;
use codex_protocol::models::{ContentItem, ResponseItem};
use codex_protocol::models::{FunctionCallOutputPayload, ResponseInputItem};
use crate::agent_tool::{create_run_agent_tool, AgentStatus, AGENT_MANAGER};
use crate::client_common::{Prompt, ResponseEvent, ResponseStream};
use crate::codex::{Session, ToolCallCtx, PRO_SUBMISSION_ID};
use crate::environment_context::EnvironmentContext;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::{create_assist_core_tool, create_pro_recommend_tool, create_pro_submit_user_tool, OpenAiTool};
use crate::protocol::{ProEvent, ProPhase};
/// Launch a background observer turn that mirrors the core session state.
pub(crate) async fn observe_now(sess: Arc<Session>, sub_id: String, reason: &'static str) {
if !sess.pro_is_enabled() {
return;
}
let tools = observer_tools(&sess, reason);
let (core_items, observer_items) = build_observer_inputs(&sess);
let mut prompt_input = Vec::new();
prompt_input.extend(core_items);
prompt_input.extend(observer_items);
let prompt = Prompt {
input: prompt_input,
store: false,
user_instructions: Some(include_str!("prompt_for_pro_observer.md").to_string()),
environment_context: Some(EnvironmentContext::new(
Some(sess.get_cwd().to_path_buf()),
Some(sess.get_approval_policy()),
Some(sess.get_sandbox_policy().clone()),
Some(sess.get_user_shell()),
)),
tools,
status_items: Vec::new(),
base_instructions_override: None,
include_additional_instructions: true,
text_format: None,
model_override: None,
model_family_override: None,
};
let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
sess.emit_pro_event(
&sub_id,
ProEvent::DeveloperNote {
turn_id: "observer_run".to_string(),
note: format!("Observer ran at {} because {}", timestamp, reason),
artifacts: Vec::new(),
},
)
.await;
let stream = match sess.model_client().stream(&prompt).await {
Ok(stream) => stream,
Err(err) => {
sess
.notify_background_event(&sub_id, format!("observer stream error: {}", err))
.await;
return;
}
};
run_observer_stream(sess, stream, sub_id).await;
}
fn observer_tools(sess: &Session, reason: &'static str) -> Vec<OpenAiTool> {
match reason {
"activity" => vec![
create_pro_recommend_tool(),
crate::openai_tools::create_wait_tool(),
create_run_agent_tool(),
],
_ => {
let mut tools = vec![
create_pro_recommend_tool(),
create_assist_core_tool(),
crate::openai_tools::create_wait_tool(),
create_run_agent_tool(),
];
if sess.pro_autonomous_enabled() {
tools.push(create_pro_submit_user_tool());
}
tools
}
}
}
async fn run_observer_stream(sess: Arc<Session>, mut stream: ResponseStream, sub_id: String) {
let started_at = Instant::now();
let mut actions: Vec<String> = Vec::new();
let mut pending_outputs: Vec<ResponseInputItem> = Vec::new();
while let Some(item) = stream.next().await {
let Ok(event) = item else { break; };
match event {
ResponseEvent::Created => {}
ResponseEvent::OutputTextDelta { .. } => {}
ResponseEvent::OutputItemDone { item, sequence_number, output_index } => {
handle_output_item(&sess, &sub_id, &mut actions, &mut pending_outputs, item, sequence_number, output_index).await;
}
ResponseEvent::Completed { .. } => {
summarize_observer_run(&sess, &sub_id, started_at.elapsed(), &actions).await;
break;
}
ResponseEvent::RateLimits(_) => {}
_ => {}
}
}
if !pending_outputs.is_empty() {
let mut history = sess
.pro_observer_history()
.lock()
.expect("poisoned lock");
for output in pending_outputs {
history.record_items([&ResponseItem::from(output)]);
}
}
}
async fn handle_output_item(
sess: &Arc<Session>,
sub_id: &str,
actions: &mut Vec<String>,
pending_outputs: &mut Vec<ResponseInputItem>,
item: ResponseItem,
sequence_number: Option<u64>,
output_index: Option<u32>,
) {
match &item {
ResponseItem::FunctionCall { name, arguments, call_id, .. } => match name.as_str() {
"pro_recommend" => {
let (title, note) = parse_recommend(arguments);
let full_note = if title.is_empty() {
note.clone()
} else if note.is_empty() {
title.clone()
} else {
format!("{}\n\n{}", title, note)
};
sess.emit_pro_event(
sub_id,
ProEvent::DeveloperNote {
turn_id: "observer".to_string(),
note: full_note,
artifacts: Vec::new(),
},
)
.await;
actions.push(format!("recommendation: {}", title));
record_observer_log(sess, &item);
record_history_item(sess, &item);
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "ok".to_string(),
success: Some(true),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &ResponseItem::from(output));
}
"assist_core" => {
let instructions = parse_instructions(arguments);
if !instructions.is_empty() {
let developer_text = format!("[Observer → Core]\n{}", instructions);
sess.add_pending_input(ResponseInputItem::Message {
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: developer_text.clone(),
}],
});
sess.emit_pro_event(
sub_id,
ProEvent::DeveloperNote {
turn_id: "observer".to_string(),
note: developer_text,
artifacts: Vec::new(),
},
)
.await;
actions.push("assist core".to_string());
}
record_observer_log(sess, &item);
record_history_item(sess, &item);
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "ok".to_string(),
success: Some(true),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &ResponseItem::from(output));
}
"wait" => {
actions.push("wait".to_string());
record_observer_log(sess, &item);
record_history_item(sess, &item);
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "waiting".to_string(),
success: Some(true),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &ResponseItem::from(output));
}
"agent_run" => {
let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), sequence_number, output_index);
let output = crate::codex::handle_run_agent(sess, &ctx, arguments.clone()).await;
pending_outputs.push(output.clone());
record_observer_log(sess, &item);
record_history_item(sess, &item);
record_history_item(sess, &ResponseItem::from(output.clone()));
actions.push("spawn agent".to_string());
let sess_clone = Arc::clone(sess);
let sub_after = sub_id.to_string();
tokio::spawn(async move {
wait_for_agents().await;
sess_clone.observer_maybe_trigger(sub_after, true, "agents_complete");
});
}
"pro_submit_user" => {
if sess.pro_autonomous_enabled() {
if let Some(text) = parse_follow_up(arguments) {
sess.submit_follow_up_user_message(text.clone()).await;
actions.push(format!("follow-up: {}", text.chars().take(40).collect::<String>()));
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "ok".to_string(),
success: Some(true),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &item);
record_history_item(sess, &ResponseItem::from(output));
}
} else {
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "autonomous disabled".to_string(),
success: Some(false),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &item);
record_history_item(sess, &ResponseItem::from(output));
}
}
other => {
let output = ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("unsupported call: {}", other),
success: Some(false),
},
};
pending_outputs.push(output.clone());
record_history_item(sess, &item);
record_history_item(sess, &ResponseItem::from(output));
}
},
ResponseItem::Message { role, .. } if role == "assistant" => {
record_observer_log(sess, &item);
record_history_item(sess, &item);
}
_ => {}
}
}
async fn summarize_observer_run(sess: &Session, sub_id: &str, elapsed: Duration, actions: &[String]) {
let mut lines = Vec::new();
lines.push(format!("Observer completed in {}s", elapsed.as_secs()));
if !actions.is_empty() {
for action in actions {
lines.push(format!("- {}", action));
}
}
sess
.emit_pro_event(
sub_id,
ProEvent::DeveloperNote {
turn_id: "observer_summary".to_string(),
note: lines.join("\n"),
artifacts: Vec::new(),
},
)
.await;
sess
.emit_pro_event(
PRO_SUBMISSION_ID,
ProEvent::Status {
phase: if actions.is_empty() { ProPhase::Idle } else { ProPhase::Background },
stats: crate::protocol::ProStats::default(),
},
)
.await;
}
fn record_observer_log(sess: &Session, item: &ResponseItem) {
let mut log = sess
.pro_observer_log()
.lock()
.expect("poisoned lock");
log.push(item.clone());
if log.len() > 80 {
let drain = log.len() - 80;
log.drain(0..drain);
}
}
fn record_history_item(sess: &Session, item: &ResponseItem) {
let mut history = sess
.pro_observer_history()
.lock()
.expect("poisoned lock");
history.record_items([item]);
}
fn build_observer_inputs(sess: &Session) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
let model_family = sess.model_client().get_model_family();
let info = get_model_info(&model_family);
let tokens = info.map(|m| m.context_window).unwrap_or(200_000) as f64;
let core_budget = (tokens * 0.40 * 4.0) as usize;
let observer_budget = (tokens * 0.10 * 4.0) as usize;
let history = sess.get_history_contents();
let mut core = Vec::new();
let mut used = 0usize;
for item in history.iter().rev() {
if let ResponseItem::Message { role, content, .. } = item {
if role == "assistant" {
let text = collect_text(content);
used += text.len();
core.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
if used >= core_budget {
break;
}
}
}
}
core.reverse();
let mut observer = Vec::new();
let mut used_obs = 0usize;
let log = sess
.pro_observer_log()
.lock()
.expect("poisoned lock")
.clone();
for item in log.iter().rev() {
let (len, converted) = clone_for_prompt(item);
used_obs += len;
observer.push(converted);
if used_obs >= observer_budget {
break;
}
}
observer.reverse();
(core, observer)
}
fn collect_text(content: &[ContentItem]) -> String {
let mut text = String::new();
for item in content {
if let ContentItem::OutputText { text: value } = item {
text.push_str(value);
}
}
text
}
fn clone_for_prompt(item: &ResponseItem) -> (usize, ResponseItem) {
match item {
ResponseItem::Message { content, .. } => {
let text = collect_text(content);
(
text.len(),
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
},
)
}
ResponseItem::FunctionCall { name, arguments, .. } => {
let snippet = arguments.chars().take(400).collect::<String>();
let text = format!("call {}({})", name, snippet);
(
text.len(),
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
},
)
}
ResponseItem::FunctionCallOutput { call_id, output } => {
let snippet = output.content.chars().take(400).collect::<String>();
let text = format!("result {}: {}", call_id, snippet);
(
text.len(),
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
},
)
}
_ => (16, item.clone()),
}
}
fn parse_recommend(arguments: &str) -> (String, String) {
serde_json::from_str::<serde_json::Value>(arguments)
.ok()
.map(|value| {
let title = value
.get("title")
.and_then(|s| s.as_str())
.unwrap_or("Recommendation")
.to_string();
let note = value
.get("note")
.and_then(|s| s.as_str())
.unwrap_or_default()
.to_string();
(title, note)
})
.unwrap_or_else(|| ("Recommendation".to_string(), String::new()))
}
fn parse_instructions(arguments: &str) -> String {
serde_json::from_str::<serde_json::Value>(arguments)
.ok()
.and_then(|value| value.get("instructions").and_then(|s| s.as_str()).map(|s| s.to_string()))
.unwrap_or_default()
}
fn parse_follow_up(arguments: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(arguments)
.ok()
.and_then(|value| value.get("text").and_then(|s| s.as_str()).map(|s| s.to_string()))
}
async fn wait_for_agents() {
loop {
let manager = AGENT_MANAGER.read().await;
let running = manager
.get_all_agents()
.any(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running));
drop(manager);
if !running {
break;
}
sleep(Duration::from_secs(2)).await;
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::task::AbortHandle;
use crate::codex::{Session, PRO_SUBMISSION_ID};
use crate::protocol::{ProEvent, ProPhase, ProStats};
/// Lightweight Pro Mode supervisor that periodically publishes status ticks.
pub struct ProSupervisorHandle {
abort: AbortHandle,
}
impl ProSupervisorHandle {
pub fn abort(self) {
if !self.abort.is_finished() {
self.abort.abort();
}
}
}
pub fn spawn(session: Arc<Session>) -> ProSupervisorHandle {
let handle = tokio::spawn(async move {
loop {
session
.emit_pro_event(
PRO_SUBMISSION_ID,
ProEvent::Status {
phase: ProPhase::Idle,
stats: ProStats::default(),
},
)
.await;
tokio::time::sleep(Duration::from_millis(1_500)).await;
}
})
.abort_handle();
ProSupervisorHandle { abort: handle }
}
@@ -0,0 +1,34 @@
In this environment, you are running Code, a fork of Codex CLI. Code is a fast, community-driven fork of Codex CLI and you have been given a specific task to perform for Code.
Your task is to OBSERVE the core CLI agent and USE AGENTS to ASSIST.
# Examples
## Planning
When you detect the core agent is performing planning, spin up agents to explore alternative plans. If their plans finish before the core agent or improve on its work, provide them as suggestions.
## Coding
When the core agent is performing a coding task, use agents to approach the task from different angles. Share improvements or edge cases that the core agent might miss.
## Problem Solving
If the core agent is solving a problem, use agents to explore different solution paths. Surface any findings that meaningfully help the task.
## Provide Context
Often the core agent lacks context. Use agents to research missing background information, then summarize the relevant results for the core agent.
# Tools
## `agent_run`
Start helper agents. Use `read_only = true` for research tasks and `read_only = false` for coding tasks. Each agent receives its own workspace when `read_only = false`.
## `assist_core`
Inject developer instructions into the core agent. Use sparingly—only when you have high-value guidance.
## `wait`
Explicitly wait for more information before acting if no immediate action is needed.
## `pro_recommend`
Send concise recommendations to the Pro HUD. Use this for status updates or suggestions that help the user move forward.
## `pro_submit_user`
When autonomous mode is enabled, submit a follow-up user message to continue the main conversation.
+96
View File
@@ -157,6 +157,11 @@ pub enum Op {
text: String,
},
/// Toggle or query Pro Mode state.
Pro {
action: ProAction,
},
/// Execute a project-scoped custom command defined in configuration.
RunProjectCommand {
name: String,
@@ -179,6 +184,20 @@ pub enum Op {
Shutdown,
}
/// Action for Pro Mode control submissions.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ProAction {
Toggle,
On,
Off,
Status,
AutoToggle,
AutoOn,
AutoOff,
AutoStatus,
}
/// Determines the conditions under which the user is consulted to approve
/// running the command proposed by Codex.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display)]
@@ -655,6 +674,9 @@ pub enum EventMsg {
/// Used after resuming from a rollout file so the user sees the full
/// history for that session without re-executing any actions.
ReplayHistory(ReplayHistoryEvent),
/// Pro Mode related events (ignored by clients that do not understand them).
Pro(ProEvent),
}
// Individual event payload types matching each `EventMsg` variant.
@@ -678,6 +700,80 @@ pub struct TokenUsage {
pub total_tokens: u64,
}
// ==================== Pro Mode (Protocol) ====================
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProEvent {
/// Toggled Pro Mode state
Toggled {
enabled: bool,
},
/// Periodic status tick
Status {
phase: ProPhase,
stats: ProStats,
},
/// A helper agent was spawned
AgentSpawned {
id: String,
category: ProCategory,
budget_ms: u64,
},
/// Result from a helper agent
AgentResult {
id: String,
category: ProCategory,
ok: bool,
note: Option<String>,
#[serde(default)]
artifacts: Vec<ProArtifact>,
},
/// Aggregated developer note for the current checkpoint
DeveloperNote {
turn_id: String,
note: String,
#[serde(default)]
artifacts: Vec<ProArtifact>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProPhase {
Idle,
Planning,
Research,
Debug,
Review,
Background,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProCategory {
Planning,
Research,
Debugging,
Review,
Background,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProStats {
pub active: u32,
pub completed: u32,
pub spawned: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProArtifact {
pub kind: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use codex_core::debug_logger::DebugLogger;
use codex_core::ContentItem;
use codex_core::LocalShellAction;
use codex_core::LocalShellExecAction;
@@ -68,7 +69,9 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
config.show_raw_agent_reasoning = true;
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let verbosity = config.model_text_verbosity;
let config = Arc::new(config);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let client = ModelClient::new(
Arc::clone(&config),
@@ -76,7 +79,9 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
provider,
effort,
summary,
verbosity,
Uuid::new_v4(),
Arc::clone(&debug_logger),
);
let mut prompt = Prompt::default();
+29 -21
View File
@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use codex_core::debug_logger::DebugLogger;
use codex_core::ContentItem;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
@@ -61,7 +62,9 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
config.show_raw_agent_reasoning = true;
let effort = config.model_reasoning_effort;
let summary = config.model_reasoning_summary;
let verbosity = config.model_text_verbosity;
let config = Arc::new(config);
let debug_logger = Arc::new(Mutex::new(DebugLogger::new(false).unwrap()));
let client = ModelClient::new(
Arc::clone(&config),
@@ -69,7 +72,9 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
provider,
effort,
summary,
verbosity,
Uuid::new_v4(),
Arc::clone(&debug_logger),
);
let mut prompt = Prompt::default();
@@ -148,12 +153,12 @@ async fn streams_text_without_reasoning() {
assert_eq!(events.len(), 3, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"),
ResponseEvent::OutputTextDelta { delta, .. } => assert_eq!(delta, "hi"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[1] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"),
ResponseEvent::OutputItemDone { item, .. } => assert_message(item, "hi"),
other => panic!("expected terminal message, got {other:?}"),
}
@@ -179,22 +184,22 @@ async fn streams_reasoning_from_string_delta() {
assert_eq!(events.len(), 5, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "think1"),
ResponseEvent::ReasoningContentDelta { delta, .. } => assert_eq!(delta, "think1"),
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[1] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"),
ResponseEvent::OutputTextDelta { delta, .. } => assert_eq!(delta, "ok"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"),
ResponseEvent::OutputItemDone { item, .. } => assert_reasoning(item, "think1"),
other => panic!("expected reasoning item, got {other:?}"),
}
match &events[3] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"),
ResponseEvent::OutputItemDone { item, .. } => assert_message(item, "ok"),
other => panic!("expected message item, got {other:?}"),
}
@@ -221,27 +226,27 @@ async fn streams_reasoning_from_object_delta() {
assert_eq!(events.len(), 6, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partA"),
ResponseEvent::ReasoningContentDelta { delta, .. } => assert_eq!(delta, "partA"),
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[1] {
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partB"),
ResponseEvent::ReasoningContentDelta { delta, .. } => assert_eq!(delta, "partB"),
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"),
ResponseEvent::OutputTextDelta { delta, .. } => assert_eq!(delta, "answer"),
other => panic!("expected text delta, got {other:?}"),
}
match &events[3] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"),
ResponseEvent::OutputItemDone { item, .. } => assert_reasoning(item, "partApartB"),
other => panic!("expected reasoning item, got {other:?}"),
}
match &events[4] {
ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"),
ResponseEvent::OutputItemDone { item, .. } => assert_message(item, "answer"),
other => panic!("expected message item, got {other:?}"),
}
@@ -263,12 +268,12 @@ async fn streams_reasoning_from_final_message() {
assert_eq!(events.len(), 3, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "final-cot"),
ResponseEvent::ReasoningContentDelta { delta, .. } => assert_eq!(delta, "final-cot"),
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[1] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"),
ResponseEvent::OutputItemDone { item, .. } => assert_reasoning(item, "final-cot"),
other => panic!("expected reasoning item, got {other:?}"),
}
@@ -293,22 +298,25 @@ async fn streams_reasoning_before_tool_call() {
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
match &events[0] {
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "pre-tool"),
ResponseEvent::ReasoningContentDelta { delta, .. } => assert_eq!(delta, "pre-tool"),
other => panic!("expected reasoning delta, got {other:?}"),
}
match &events[1] {
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"),
ResponseEvent::OutputItemDone { item, .. } => assert_reasoning(item, "pre-tool"),
other => panic!("expected reasoning item, got {other:?}"),
}
match &events[2] {
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall {
name,
arguments,
call_id,
ResponseEvent::OutputItemDone {
item: ResponseItem::FunctionCall {
name,
arguments,
call_id,
..
},
..
}) => {
} => {
assert_eq!(name, "run");
assert_eq!(arguments, "{}");
assert_eq!(call_id, "call_1");
-1
View File
@@ -1,6 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use codex_core::config_types::McpServerConfig;
use codex_core::mcp_connection_manager::McpConnectionManager;
@@ -729,6 +729,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::UserMessage(_) => {}
EventMsg::EnteredReviewMode(_) => {}
EventMsg::ExitedReviewMode(_) => {}
EventMsg::Pro(_) => {}
}
CodexStatus::Running
}
+2
View File
@@ -71,6 +71,7 @@ async fn python_multiprocessing_lock_works_under_sandbox() {
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
allow_git_writes: true,
};
let python_code = r#"import multiprocessing
@@ -130,6 +131,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() {
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
allow_git_writes: true,
};
// Attempt to write inside the command cwd, which is outside of the sandbox policy cwd.
@@ -293,6 +293,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::ShutdownComplete
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::Pro(_)
| EventMsg::CustomToolCallBegin(_)
| EventMsg::CustomToolCallEnd(_) => {
// For now, we do not do anything extra for these
+14
View File
@@ -1361,6 +1361,20 @@ impl App<'_> {
widget.handle_agents_command(command_args);
}
}
SlashCommand::Pro => {
if let AppState::Chat { widget } = &mut self.app_state {
match widget.parse_pro_action(&command_args) {
Ok(action) => {
let _ = self
.app_event_tx
.send(AppEvent::CodexOp(Op::Pro { action }));
}
Err(err) => {
widget.history_push(history_cell::new_error_event(err));
}
}
}
}
SlashCommand::Github => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_github_command(command_args);
+1 -1
View File
@@ -135,7 +135,7 @@ impl TextArea {
self.wrapped_lines(width).len() as u16
}
#[cfg_attr(not(test), allow(dead_code))]
#[allow(dead_code)]
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_with_state(area, &TextAreaState::default())
}
+800 -61
View File
@@ -86,6 +86,11 @@ use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::ProAction;
use codex_core::protocol::ProEvent;
use codex_core::protocol::ProPhase;
use codex_core::protocol::ProStats;
use codex_core::protocol::ProCategory;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use image::imageops::FilterType;
@@ -97,6 +102,7 @@ use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui_image::picker::Picker;
use std::cell::Cell;
use std::cell::RefCell;
use std::sync::mpsc;
use std::fs::OpenOptions;
@@ -158,7 +164,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::plan_tool::StepStatus;
use codex_core::protocol::RateLimitSnapshotEvent;
use crate::rate_limits_view::RateLimitResetInfo;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Local, Utc};
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
@@ -323,6 +329,7 @@ pub(crate) struct ChatWidget<'a> {
active_plan_title: Option<String>,
/// Runtime timing per-agent (by id) to improve visibility in the HUD
agent_runtime: HashMap<String, AgentRuntime>,
pro: ProState,
// Sparkline data for showing agent activity (using RefCell for interior mutability)
// Each tuple is (value, is_completed) where is_completed indicates if any agent was complete at that time
sparkline_data: std::cell::RefCell<Vec<(u64, bool)>>,
@@ -2136,6 +2143,7 @@ impl ChatWidget<'_> {
overall_task_status: "preparing".to_string(),
active_plan_title: None,
agent_runtime: HashMap::new(),
pro: ProState::default(),
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
@@ -2178,6 +2186,7 @@ impl ChatWidget<'_> {
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
pro_hud_expanded: false,
last_frame_height: std::cell::Cell::new(0),
last_frame_width: std::cell::Cell::new(0),
},
@@ -2343,6 +2352,7 @@ impl ChatWidget<'_> {
overall_task_status: "preparing".to_string(),
active_plan_title: None,
agent_runtime: HashMap::new(),
pro: ProState::default(),
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
stream: crate::streaming::controller::StreamController::new(config.clone()),
@@ -2385,6 +2395,7 @@ impl ChatWidget<'_> {
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
pro_hud_expanded: false,
last_frame_height: std::cell::Cell::new(0),
last_frame_width: std::cell::Cell::new(0),
},
@@ -2553,6 +2564,14 @@ impl ChatWidget<'_> {
if self.diffs.overlay.is_some() {
return;
}
if self.pro.overlay_visible {
if self.handle_pro_overlay_key(key_event) {
return;
}
if self.pro.overlay_visible {
return;
}
}
if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint();
}
@@ -2580,6 +2599,23 @@ impl ChatWidget<'_> {
self.toggle_agents_hud();
return;
}
if let KeyEvent {
code: crossterm::event::KeyCode::Char('p'),
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} = key_event
{
use crossterm::event::KeyModifiers;
if modifiers.contains(KeyModifiers::CONTROL) && modifiers.contains(KeyModifiers::SHIFT) {
self.toggle_pro_hud();
return;
}
if modifiers == KeyModifiers::CONTROL {
self.toggle_pro_overlay();
return;
}
}
// Fast-path PageUp/PageDown to scroll the transcript by a viewport at a time.
if let crossterm::event::KeyEvent {
@@ -2730,6 +2766,96 @@ impl ChatWidget<'_> {
layout_scroll::toggle_agents_hud(self);
}
fn toggle_pro_hud(&mut self) {
layout_scroll::toggle_pro_hud(self);
}
fn toggle_pro_overlay(&mut self) {
let new_state = !self.pro.overlay_visible;
self.pro.overlay_visible = new_state;
if new_state {
let overlay = self.pro.ensure_overlay();
overlay.set_scroll(0);
}
self.request_redraw();
}
fn close_pro_overlay(&mut self) {
if self.pro.overlay_visible {
self.pro.overlay_visible = false;
self.request_redraw();
}
}
fn handle_pro_overlay_key(&mut self, key_event: KeyEvent) -> bool {
if !self.pro.overlay_visible {
return false;
}
let Some(overlay) = self.pro.overlay.as_ref() else {
return false;
};
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return true;
}
use crossterm::event::{KeyCode, KeyModifiers};
match key_event.code {
KeyCode::Esc => {
self.close_pro_overlay();
true
}
KeyCode::Char('p') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
self.toggle_pro_overlay();
true
}
KeyCode::Up => {
let current = overlay.scroll();
if current > 0 {
overlay.set_scroll(current.saturating_sub(1));
self.request_redraw();
}
true
}
KeyCode::Down => {
let current = overlay.scroll();
let max = overlay.max_scroll();
let next = current.saturating_add(1).min(max);
if next != current {
overlay.set_scroll(next);
self.request_redraw();
}
true
}
KeyCode::PageUp => {
let step = overlay.visible_rows().max(1);
let current = overlay.scroll();
let next = current.saturating_sub(step);
overlay.set_scroll(next);
self.request_redraw();
true
}
KeyCode::PageDown => {
let step = overlay.visible_rows().max(1);
let current = overlay.scroll();
let max = overlay.max_scroll();
let next = current.saturating_add(step).min(max);
overlay.set_scroll(next);
self.request_redraw();
true
}
KeyCode::Home => {
overlay.set_scroll(0);
self.request_redraw();
true
}
KeyCode::End => {
overlay.set_scroll(overlay.max_scroll());
self.request_redraw();
true
}
_ => false,
}
}
// dispatch_command() removed — command routing is handled at the App layer via AppEvent::DispatchCommand
pub(crate) fn handle_paste(&mut self, text: String) {
@@ -3742,6 +3868,117 @@ impl ChatWidget<'_> {
}
}
fn handle_pro_event(&mut self, event: ProEvent) {
match event {
ProEvent::Toggled { enabled } => {
self.pro.set_enabled(enabled);
let title = if enabled {
"Pro mode enabled"
} else {
"Pro mode disabled"
};
self.pro
.push_log(ProLogEntry::new(title, None, ProLogCategory::Status));
}
ProEvent::Status { phase, stats } => {
self.pro.update_status(phase.clone(), stats.clone());
}
ProEvent::DeveloperNote { turn_id, note, artifacts } => {
let lower = note.to_ascii_lowercase();
if lower.contains("autonomous") && lower.contains("enabled") {
self.pro.set_auto_enabled(true);
} else if lower.contains("autonomous") && lower.contains("disabled") {
self.pro.set_auto_enabled(false);
}
let mut body_lines = vec![note.clone()];
for artifact in artifacts {
if !artifact.summary.is_empty() {
body_lines.push(format!("{}: {}", artifact.kind, artifact.summary));
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let category = if turn_id.contains("observer") {
ProLogCategory::Recommendation
} else {
ProLogCategory::Note
};
self.pro
.push_log(ProLogEntry::new("Developer note", body, category));
}
ProEvent::AgentSpawned { category, budget_ms, .. } => {
let title = format!(
"{} helper spawned",
self.describe_pro_category(&category)
);
let body = if budget_ms > 0 {
Some(format!("Budget: {} ms", budget_ms))
} else {
None
};
self.pro
.push_log(ProLogEntry::new(title, body, ProLogCategory::Agent));
}
ProEvent::AgentResult {
category,
ok,
note,
artifacts,
..
} => {
let status = if ok { "completed" } else { "failed" };
let title = format!(
"{} helper {}",
self.describe_pro_category(&category),
status
);
let mut body_lines = Vec::new();
if let Some(note) = note {
if !note.is_empty() {
body_lines.push(note);
}
}
for artifact in artifacts {
if !artifact.summary.is_empty() {
body_lines.push(format!("{}: {}", artifact.kind, artifact.summary));
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
self.pro
.push_log(ProLogEntry::new(title, body, ProLogCategory::Agent));
}
}
self.request_redraw();
}
fn describe_pro_category(&self, category: &ProCategory) -> &'static str {
match category {
ProCategory::Planning => "Planning",
ProCategory::Research => "Research",
ProCategory::Debugging => "Debugging",
ProCategory::Review => "Review",
ProCategory::Background => "Background",
}
}
fn describe_pro_phase(&self, phase: &ProPhase) -> &'static str {
match phase {
ProPhase::Idle => "Idle",
ProPhase::Planning => "Planning",
ProPhase::Research => "Research",
ProPhase::Debug => "Debug",
ProPhase::Review => "Review",
ProPhase::Background => "Background",
}
}
pub(crate) fn handle_codex_event(&mut self, event: Event) {
tracing::debug!(
"handle_codex_event({})",
@@ -3786,6 +4023,9 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::Pro(event) => {
self.handle_pro_event(event);
}
EventMsg::WebSearchBegin(ev) => {
// Enforce order presence (tool events should carry it)
let ok = match event.order.as_ref() {
@@ -12520,6 +12760,11 @@ impl ChatWidget<'_> {
.map(|lock| lock.is_some())
.unwrap_or(false);
let has_active_agents = !self.active_agents.is_empty() || self.agents_ready_to_start;
let has_pro = self.pro_surface_present();
if !has_browser_screenshot && !has_active_agents && !has_pro {
return;
}
// Add same horizontal padding as the Message input (2 chars on each side)
let horizontal_padding = 1u16;
@@ -12529,77 +12774,77 @@ impl ChatWidget<'_> {
width: area.width.saturating_sub(horizontal_padding * 2),
height: area.height,
};
if padded_area.height == 0 {
return;
}
// Determine layout based on what's active (stacked, full width)
let header_h: u16 = 3;
// Expanded target based on full terminal height per spec
let term_h = self.layout.last_frame_height.get().max(1);
let thirty = ((term_h as u32) * 30 / 100) as u16;
let sixty = ((term_h as u32) * 60 / 100) as u16;
let mut expanded_target = if thirty < 25 { 25.min(sixty) } else { thirty };
// Make sure expanded chunk includes space for header + spacer
let min_expanded = header_h.saturating_add(2);
if expanded_target < min_expanded {
expanded_target = min_expanded;
}
match (has_active_agents, has_browser_screenshot) {
(true, true) => {
let (top_h, bottom_h) =
if self.layout.agents_hud_expanded && !self.layout.browser_hud_expanded {
(
expanded_target.min(padded_area.height.saturating_sub(0)),
header_h,
)
} else if self.layout.browser_hud_expanded && !self.layout.agents_hud_expanded {
(
header_h,
expanded_target.min(padded_area.height.saturating_sub(0)),
)
} else {
let top = header_h.min(padded_area.height);
let bottom = padded_area.height.saturating_sub(top).min(header_h);
(top, bottom)
};
let chunks =
Layout::vertical([Constraint::Length(top_h), Constraint::Length(bottom_h)])
.areas::<2>(padded_area);
// Agents on top
if self.layout.agents_hud_expanded {
self.render_agent_panel(chunks[0], buf);
} else {
self.render_agents_header(chunks[0], buf);
}
// Browser on bottom
if self.layout.browser_hud_expanded {
self.render_browser_panel(chunks[1], buf);
} else {
self.render_browser_header(chunks[1], buf);
}
#[derive(Copy, Clone)]
enum HudKind {
Browser,
Agents,
Pro,
}
let mut panels: Vec<(HudKind, bool)> = Vec::new();
if has_browser_screenshot {
panels.push((HudKind::Browser, self.layout.browser_hud_expanded));
}
if has_active_agents {
panels.push((HudKind::Agents, self.layout.agents_hud_expanded));
}
if has_pro {
panels.push((HudKind::Pro, self.layout.pro_hud_expanded));
}
if panels.is_empty() {
return;
}
let mut constraints: Vec<Constraint> = Vec::with_capacity(panels.len());
let mut remaining = padded_area.height;
for (idx, (_, expanded)) in panels.iter().enumerate() {
if remaining == 0 {
constraints.push(Constraint::Length(0));
continue;
}
(true, false) => {
if self.layout.agents_hud_expanded {
let h = expanded_target.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_agent_panel(a, buf);
} else {
let h = header_h.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_agents_header(a, buf);
}
let desired = if *expanded {
expanded_target.min(remaining)
} else {
header_h.min(remaining)
};
let length = if idx == panels.len() - 1 {
desired.max(remaining)
} else {
desired
};
let length = length.min(remaining);
constraints.push(Constraint::Length(length));
remaining = remaining.saturating_sub(length);
}
let chunks = Layout::vertical(constraints).split(padded_area);
let count = panels.len().min(chunks.len());
for idx in 0..count {
let rect = chunks[idx];
let (kind, expanded) = panels[idx];
match (kind, expanded) {
(HudKind::Browser, true) => self.render_browser_panel(rect, buf),
(HudKind::Browser, false) => self.render_browser_header(rect, buf),
(HudKind::Agents, true) => self.render_agent_panel(rect, buf),
(HudKind::Agents, false) => self.render_agents_header(rect, buf),
(HudKind::Pro, true) => self.render_pro_panel(rect, buf),
(HudKind::Pro, false) => self.render_pro_header(rect, buf),
}
(false, true) => {
if self.layout.browser_hud_expanded {
let h = expanded_target.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_browser_panel(a, buf);
} else {
let h = header_h.min(padded_area.height);
let [a] = Layout::vertical([Constraint::Length(h)]).areas::<1>(padded_area);
self.render_browser_header(a, buf);
}
}
(false, false) => {}
}
}
@@ -12751,6 +12996,381 @@ impl ChatWidget<'_> {
Paragraph::new(RLine::from(spans)).render(content, buf);
}
fn render_pro_header(&self, area: Rect, buf: &mut Buffer) {
use ratatui::layout::Margin;
use ratatui::text::Line as RLine;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.title(" Pro ");
let inner = block.inner(area);
block.render(area, buf);
let content = inner.inner(Margin::new(1, 0));
let dot_color = if self.pro.enabled {
crate::colors::success_green()
} else {
crate::colors::text_dim()
};
let mut left_spans: Vec<Span> = Vec::new();
left_spans.push(Span::styled("•", Style::default().fg(dot_color)));
left_spans.push(Span::raw(" "));
left_spans.push(Span::raw(self.pro_summary_line()));
let action = if self.layout.pro_hud_expanded {
" collapse"
} else {
" expand"
};
let key_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().dim();
let mut right_spans: Vec<Span> = Vec::new();
right_spans.push(Span::from("Ctrl+Shift+P").style(key_style));
right_spans.push(Span::styled(action, label_style));
right_spans.push(Span::raw(" "));
right_spans.push(Span::from("Ctrl+P").style(key_style));
right_spans.push(Span::styled(" overlay", label_style));
let measure =
|spans: &Vec<Span>| -> usize { spans.iter().map(|s| s.content.chars().count()).sum() };
let left_len = measure(&left_spans);
let right_len = measure(&right_spans);
let total_width = content.width as usize;
if total_width > left_len + right_len {
left_spans.push(Span::from(" ".repeat(total_width - left_len - right_len)));
}
let mut spans = left_spans;
spans.extend(right_spans);
Paragraph::new(RLine::from(spans)).render(content, buf);
}
fn render_pro_panel(&self, area: Rect, buf: &mut Buffer) {
use ratatui::layout::Margin;
use ratatui::text::Line as RLine;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.title(" Pro ");
let inner = block.inner(area);
block.render(area, buf);
let content = inner.inner(Margin::new(1, 0));
if content.height == 0 {
return;
}
let mut lines: Vec<RLine<'static>> = Vec::new();
let summary_style = Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD);
lines.push(RLine::from(vec![Span::styled(
self.pro_summary_line(),
summary_style,
)]));
let key_style = Style::default().fg(crate::colors::function());
let label_style = Style::default().fg(crate::colors::text_dim());
lines.push(RLine::from(vec![
Span::raw(" "),
Span::from("Ctrl+Shift+P").style(key_style),
Span::styled(" collapse ", label_style),
Span::from("Ctrl+P").style(key_style),
Span::styled(" overlay", label_style),
]));
lines.push(RLine::from(" "));
if self.pro.log.is_empty() {
lines.push(RLine::from(vec![Span::styled(
"No Pro activity yet",
Style::default().fg(crate::colors::text_dim()),
)]));
} else {
for entry in self.pro.log.iter().rev() {
for line in self.format_pro_log_entry(entry) {
lines.push(line);
}
lines.push(RLine::from(" "));
}
// Remove trailing blank line for neatness
if lines
.last()
.map(|line| line.spans.iter().all(|s| s.content.trim().is_empty()))
.unwrap_or(false)
{
lines.pop();
}
}
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.render(content, buf);
}
fn render_pro_overlay(&self, frame_area: Rect, history_area: Rect, buf: &mut Buffer) {
use ratatui::layout::Margin;
use ratatui::text::Line as RLine;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
let Some(overlay) = self.pro.overlay.as_ref() else {
return;
};
// Dim entire frame as scrim
let scrim_style = Style::default()
.bg(crate::colors::overlay_scrim())
.fg(crate::colors::text_dim());
fill_rect(buf, frame_area, None, scrim_style);
// Match horizontal padding used by history content
let padding = 1u16;
let overlay_area = Rect {
x: history_area.x + padding,
y: history_area.y,
width: history_area.width.saturating_sub(padding * 2),
height: history_area.height,
};
Clear.render(overlay_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(RLine::from(vec![
Span::styled(" Pro activity ", Style::default().fg(crate::colors::text())),
Span::styled("— Esc close ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Ctrl+P overlay ", Style::default().fg(crate::colors::text_dim())),
Span::styled("↑↓ scroll", Style::default().fg(crate::colors::text_dim())),
]))
.style(Style::default().bg(crate::colors::background()))
.border_style(
Style::default()
.fg(crate::colors::border())
.bg(crate::colors::background()),
);
let inner = block.inner(overlay_area);
block.render(overlay_area, buf);
let body = inner.inner(Margin::new(1, 1));
if body.height == 0 {
return;
}
let mut lines: Vec<RLine<'static>> = Vec::new();
let summary_style = Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD);
lines.push(RLine::from(vec![Span::styled(
self.pro_summary_line(),
summary_style,
)]));
lines.push(RLine::from(" "));
if self.pro.log.is_empty() {
lines.push(RLine::from(vec![Span::styled(
"No Pro activity captured yet",
Style::default().fg(crate::colors::text_dim()),
)]));
} else {
for entry in self.pro.log.iter().rev() {
for line in self.format_pro_log_entry(entry) {
lines.push(line);
}
lines.push(RLine::from(" "));
}
}
while lines
.last()
.map(|line| line.spans.iter().all(|s| s.content.trim().is_empty()))
.unwrap_or(false)
{
lines.pop();
}
let total_lines = lines.len();
let visible_rows = body.height as usize;
overlay.set_visible_rows(body.height);
let max_scroll = total_lines.saturating_sub(visible_rows.max(1));
overlay.set_max_scroll(max_scroll.min(u16::MAX as usize) as u16);
let skip = overlay.scroll().min(overlay.max_scroll()) as usize;
let end = (skip + visible_rows).min(total_lines);
let slice = if skip < total_lines {
lines[skip..end].to_vec()
} else {
Vec::new()
};
let paragraph = Paragraph::new(slice).wrap(Wrap { trim: false });
paragraph.render(body, buf);
}
fn pro_summary_line(&self) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(if self.pro.enabled { "on" } else { "off" }.to_string());
parts.push(format!(
"auto {}",
if self.pro.auto_enabled { "on" } else { "off" }
));
if let Some(status) = &self.pro.status {
parts.push(self.describe_pro_phase(&status.phase).to_string());
parts.push(format!(
"A{}/C{}/S{}",
status.stats.active, status.stats.completed, status.stats.spawned
));
}
if let Some(ts) = self.pro.last_status_update {
parts.push(format!("updated {}", self.format_recent_timestamp(ts)));
}
parts.join(" · ")
}
fn format_pro_log_entry(&self, entry: &ProLogEntry) -> Vec<ratatui::text::Line<'static>> {
use ratatui::text::Span;
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
let timestamp = entry.timestamp.format("%H:%M:%S").to_string();
let mut header_spans: Vec<Span<'static>> = Vec::new();
header_spans.push(Span::styled(
timestamp,
Style::default().fg(crate::colors::text_dim()),
));
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
entry.title.clone(),
Style::default()
.fg(self.pro_category_color(entry.category))
.add_modifier(Modifier::BOLD),
));
lines.push(ratatui::text::Line::from(header_spans));
if let Some(body) = &entry.body {
for body_line in body.lines() {
let trimmed = body_line.trim();
if trimmed.is_empty() {
continue;
}
lines.push(ratatui::text::Line::from(Span::raw(format!(
" {}",
trimmed
))));
}
}
lines
}
fn pro_category_color(&self, category: ProLogCategory) -> ratatui::style::Color {
match category {
ProLogCategory::Status => crate::colors::text(),
ProLogCategory::Recommendation => crate::colors::primary(),
ProLogCategory::Agent => crate::colors::info(),
ProLogCategory::Note => crate::colors::text_mid(),
}
}
pub(crate) fn parse_pro_action(&self, args: &str) -> Result<ProAction, String> {
let trimmed = args.trim();
if trimmed.is_empty() {
return Ok(ProAction::Status);
}
let mut parts = trimmed.split_whitespace();
let first = parts
.next()
.unwrap_or("")
.to_ascii_lowercase();
let ensure_no_extra = |iter: &mut dyn Iterator<Item = &str>| {
if iter.next().is_some() {
Err("Too many arguments for /pro [auto] command".to_string())
} else {
Ok(())
}
};
match first.as_str() {
"toggle" | "switch" => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::Toggle)
}
"on" | "enable" | "start" => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::On)
}
"off" | "disable" | "stop" => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::Off)
}
"status" | "state" => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::Status)
}
"auto" => {
let next = parts.next().map(|s| s.to_ascii_lowercase());
match next.as_deref() {
None => Ok(ProAction::AutoToggle),
Some("toggle" | "switch") => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::AutoToggle)
}
Some("on" | "enable" | "start") => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::AutoOn)
}
Some("off" | "disable" | "stop") => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::AutoOff)
}
Some("status" | "state") => {
ensure_no_extra(&mut parts)?;
Ok(ProAction::AutoStatus)
}
Some(other) => Err(format!("Unknown /pro auto option: {}", other)),
}
}
other => Err(format!("Unknown /pro subcommand: {}", other)),
}
}
fn pro_surface_present(&self) -> bool {
self.pro.enabled
|| self.pro.auto_enabled
|| self.pro.status.is_some()
|| !self.pro.log.is_empty()
|| self.pro.overlay_visible
}
fn format_recent_timestamp(&self, timestamp: DateTime<Local>) -> String {
let now = Local::now();
let delta = now.signed_duration_since(timestamp);
if delta.num_seconds() < 0 {
return "just now".to_string();
}
if delta.num_seconds() < 10 {
return "just now".to_string();
}
if delta.num_seconds() < 60 {
return format!("{}s ago", delta.num_seconds());
}
if delta.num_minutes() < 60 {
return format!("{}m ago", delta.num_minutes());
}
if delta.num_hours() < 24 {
return format!("{}h ago", delta.num_hours());
}
timestamp.format("%b %e %H:%M").to_string()
}
/// Render a collapsed header for the agents HUD with counts/list (1 line + border)
fn render_agents_header(&self, area: Rect, buf: &mut Buffer) {
use ratatui::layout::Margin;
@@ -14504,8 +15124,9 @@ impl WidgetRef for &ChatWidget<'_> {
// The welcome animation is no longer rendered as an overlay.
if self.terminal.overlay().is_none() {
// Render diff overlay (covering the history area, aligned with padding) if active
if let Some(overlay) = &self.diffs.overlay {
if self.pro.overlay_visible {
self.render_pro_overlay(area, history_area, buf);
} else if let Some(overlay) = &self.diffs.overlay {
// Global scrim: dim the whole background to draw focus to the viewer
// We intentionally do this across the entire widget area rather than just the
// history area so the viewer stands out even with browser HUD or status bars.
@@ -15102,10 +15723,128 @@ struct LayoutState {
last_hud_present: std::cell::Cell<bool>,
browser_hud_expanded: bool,
agents_hud_expanded: bool,
pro_hud_expanded: bool,
last_frame_height: std::cell::Cell<u16>,
last_frame_width: std::cell::Cell<u16>,
}
#[derive(Default)]
struct ProState {
enabled: bool,
auto_enabled: bool,
status: Option<ProStatusSnapshot>,
last_status_update: Option<DateTime<Local>>,
log: Vec<ProLogEntry>,
overlay: Option<ProOverlay>,
overlay_visible: bool,
}
#[derive(Clone)]
struct ProStatusSnapshot {
phase: ProPhase,
stats: ProStats,
}
#[derive(Clone)]
struct ProLogEntry {
timestamp: DateTime<Local>,
title: String,
body: Option<String>,
category: ProLogCategory,
}
#[derive(Clone, Copy)]
enum ProLogCategory {
Status,
Recommendation,
Agent,
Note,
}
struct ProOverlay {
scroll: Cell<u16>,
max_scroll: Cell<u16>,
visible_rows: Cell<u16>,
}
impl ProOverlay {
fn new() -> Self {
Self {
scroll: Cell::new(0),
max_scroll: Cell::new(0),
visible_rows: Cell::new(0),
}
}
fn scroll(&self) -> u16 {
self.scroll.get()
}
fn set_scroll(&self, value: u16) {
let max = self.max_scroll.get();
self.scroll.set(value.min(max));
}
fn set_max_scroll(&self, max: u16) {
self.max_scroll.set(max);
self.set_scroll(self.scroll.get());
}
fn set_visible_rows(&self, rows: u16) {
self.visible_rows.set(rows);
}
fn visible_rows(&self) -> u16 {
self.visible_rows.get()
}
fn max_scroll(&self) -> u16 {
self.max_scroll.get()
}
}
impl ProLogEntry {
fn new(title: impl Into<String>, body: Option<String>, category: ProLogCategory) -> Self {
Self {
timestamp: Local::now(),
title: title.into(),
body,
category,
}
}
}
impl ProState {
fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
fn set_auto_enabled(&mut self, enabled: bool) {
self.auto_enabled = enabled;
}
fn update_status(&mut self, phase: ProPhase, stats: ProStats) {
self.status = Some(ProStatusSnapshot { phase, stats });
self.last_status_update = Some(Local::now());
}
fn push_log(&mut self, entry: ProLogEntry) {
const MAX_LOG_ENTRIES: usize = 200;
self.log.push(entry);
if self.log.len() > MAX_LOG_ENTRIES {
let excess = self.log.len() - MAX_LOG_ENTRIES;
self.log.drain(0..excess);
}
}
fn ensure_overlay(&mut self) -> &mut ProOverlay {
if self.overlay.is_none() {
self.overlay = Some(ProOverlay::new());
}
self.overlay.as_mut().unwrap()
}
}
#[derive(Default)]
struct DiffsState {
session_patch_sets: Vec<HashMap<PathBuf, codex_core::protocol::FileChange>>,
+38 -6
View File
@@ -113,7 +113,10 @@ pub(super) fn to_bottom(chat: &mut ChatWidget<'_>) {
pub(super) fn toggle_browser_hud(chat: &mut ChatWidget<'_>) {
let new_state = !chat.layout.browser_hud_expanded;
chat.layout.browser_hud_expanded = new_state;
if new_state { chat.layout.agents_hud_expanded = false; }
if new_state {
chat.layout.agents_hud_expanded = false;
chat.layout.pro_hud_expanded = false;
}
chat.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
chat.request_redraw();
}
@@ -121,11 +124,27 @@ pub(super) fn toggle_browser_hud(chat: &mut ChatWidget<'_>) {
pub(super) fn toggle_agents_hud(chat: &mut ChatWidget<'_>) {
let new_state = !chat.layout.agents_hud_expanded;
chat.layout.agents_hud_expanded = new_state;
if new_state { chat.layout.browser_hud_expanded = false; }
if new_state {
chat.layout.browser_hud_expanded = false;
chat.layout.pro_hud_expanded = false;
}
chat.height_manager.borrow_mut().record_event(HeightEvent::HudToggle(true));
chat.request_redraw();
}
pub(super) fn toggle_pro_hud(chat: &mut ChatWidget<'_>) {
let new_state = !chat.layout.pro_hud_expanded;
chat.layout.pro_hud_expanded = new_state;
if new_state {
chat.layout.browser_hud_expanded = false;
chat.layout.agents_hud_expanded = false;
}
chat.height_manager
.borrow_mut()
.record_event(HeightEvent::HudToggle(true));
chat.request_redraw();
}
pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
let has_browser_screenshot = chat
.latest_browser_screenshot
@@ -133,8 +152,13 @@ pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
.map(|lock| lock.is_some())
.unwrap_or(false);
let has_active_agents = !chat.active_agents.is_empty() || chat.agents_ready_to_start;
let has_pro_panel = chat.pro_surface_present();
// In standard terminal mode, suppress HUD entirely.
let hud_present = if chat.standard_terminal_mode { false } else { has_browser_screenshot || has_active_agents };
let hud_present = if chat.standard_terminal_mode {
false
} else {
has_browser_screenshot || has_active_agents || has_pro_panel
};
let bottom_desired = chat.bottom_pane.desired_height(area.width);
let font_cell = chat.measured_font_size();
@@ -147,7 +171,9 @@ pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
}
let collapsed_unit: u16 = 3;
let present_count: u16 = (has_active_agents as u16) + (has_browser_screenshot as u16);
let present_count: u16 = (has_active_agents as u16)
+ (has_browser_screenshot as u16)
+ (has_pro_panel as u16);
let hud_target: Option<u16> = if !hud_present || present_count == 0 {
None
} else {
@@ -157,8 +183,14 @@ pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
let sixty = ((term_h as u32) * 60 / 100) as u16;
let mut expanded = if thirty < 25 { 25.min(sixty) } else { thirty };
expanded = expanded.max(collapsed_unit.saturating_add(2));
let any_expanded = chat.layout.browser_hud_expanded || chat.layout.agents_hud_expanded;
let target = if any_expanded { base_collapsed.saturating_add(expanded) } else { base_collapsed };
let any_expanded = (chat.layout.browser_hud_expanded && has_browser_screenshot)
|| (chat.layout.agents_hud_expanded && has_active_agents)
|| (chat.layout.pro_hud_expanded && has_pro_panel);
let target = if any_expanded {
base_collapsed.saturating_add(expanded)
} else {
base_collapsed
};
Some(target)
};
+22
View File
@@ -23,6 +23,7 @@ use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::ProAction;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use crossterm::event::KeyCode;
@@ -306,6 +307,27 @@ fn slash_agents_opens_overview() {
);
}
#[test]
fn parse_pro_action_supports_core_variants() {
let (chat, _rx, _op_rx) = make_chatwidget_manual();
assert_eq!(chat.parse_pro_action(""), Ok(ProAction::Status));
assert_eq!(chat.parse_pro_action("toggle"), Ok(ProAction::Toggle));
assert_eq!(chat.parse_pro_action("on"), Ok(ProAction::On));
assert_eq!(chat.parse_pro_action("off"), Ok(ProAction::Off));
assert_eq!(chat.parse_pro_action("status"), Ok(ProAction::Status));
assert_eq!(chat.parse_pro_action("auto"), Ok(ProAction::AutoToggle));
assert_eq!(chat.parse_pro_action("auto on"), Ok(ProAction::AutoOn));
assert_eq!(chat.parse_pro_action("auto off"), Ok(ProAction::AutoOff));
assert_eq!(
chat.parse_pro_action("auto status"),
Ok(ProAction::AutoStatus)
);
let err = chat.parse_pro_action("auto maybe").unwrap_err();
assert!(err.contains("Unknown /pro auto option"));
}
#[test]
fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
+6
View File
@@ -6245,6 +6245,12 @@ fn popular_commands_lines(latest_version: Option<&str>) -> Vec<Line<'static>> {
.style(Style::default().add_modifier(Modifier::DIM)),
Span::styled(" NEW", Style::default().fg(crate::colors::primary())),
]));
lines.push(Line::from(vec![
Span::styled("/pro", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Pro.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/model", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
+2
View File
@@ -34,6 +34,7 @@ pub enum SlashCommand {
Prompts,
Perf,
Agents,
Pro,
Branch,
Merge,
Github,
@@ -76,6 +77,7 @@ impl SlashCommand {
SlashCommand::Prompts => "show example prompts",
SlashCommand::Model => "choose model & reasoning effort",
SlashCommand::Agents => "create and configure agents",
SlashCommand::Pro => "manage Pro mode (toggle/status/auto)",
SlashCommand::Branch => {
"work in an isolated /branch then /merge when done (great for parallel work)"
}
+2
View File
@@ -54,6 +54,8 @@ Notes
- `/perf (on|off|show|reset)`: performance tracing controls.
- `/agents`: list agents (running and availability).
- `/pro [on|off|toggle|status|auto (on|off|toggle|status)]`: manage Pro mode,
including autonomous follow-ups and observer status.
## Prompt‑Expanding (Multi‑Agent)