Move TUI on top of app server (parallel code) (#14717)

This PR replicates the `tui` code directory and creates a temporary
parallel `tui_app_server` directory. It also implements a new feature
flag `tui_app_server` to select between the two tui implementations.

Once the new app-server-based TUI is stabilized, we'll delete the old
`tui` directory and feature flag.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-03-16 10:49:19 -06:00
1 parent c04a0a7454
commit db89b73a9c
1109 files changed
+134253 -17

No files matched your search

@@ -0,0 +1,83 @@
use codex_protocol::protocol::SandboxPolicy;
use std::path::PathBuf;
/// Returns a warning describing why `--add-dir` entries will be ignored for the
/// resolved sandbox policy. The caller is responsible for presenting the
/// warning to the user (for example, printing to stderr).
pub fn add_dir_warning_message(
additional_dirs: &[PathBuf],
sandbox_policy: &SandboxPolicy,
) -> Option<String> {
if additional_dirs.is_empty() {
return None;
}
match sandbox_policy {
SandboxPolicy::WorkspaceWrite { .. }
| SandboxPolicy::DangerFullAccess
| SandboxPolicy::ExternalSandbox { .. } => None,
SandboxPolicy::ReadOnly { .. } => Some(format_warning(additional_dirs)),
}
}
fn format_warning(additional_dirs: &[PathBuf]) -> String {
let joined_paths = additional_dirs
.iter()
.map(|path| path.to_string_lossy())
.collect::<Vec<_>>()
.join(", ");
format!(
"Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots."
)
}
#[cfg(test)]
mod tests {
use super::add_dir_warning_message;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn returns_none_for_workspace_write() {
let sandbox = SandboxPolicy::new_workspace_write_policy();
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
#[test]
fn returns_none_for_danger_full_access() {
let sandbox = SandboxPolicy::DangerFullAccess;
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
#[test]
fn returns_none_for_external_sandbox() {
let sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
};
let dirs = vec![PathBuf::from("/tmp/example")];
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
#[test]
fn warns_for_read_only() {
let sandbox = SandboxPolicy::new_read_only_policy();
let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")];
let message = add_dir_warning_message(&dirs, &sandbox)
.expect("expected warning for read-only sandbox");
assert_eq!(
message,
"Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots."
);
}
#[test]
fn returns_none_when_no_additional_dirs() {
let sandbox = SandboxPolicy::new_read_only_policy();
let dirs: Vec<PathBuf> = Vec::new();
assert_eq!(add_dir_warning_message(&dirs, &sandbox), None);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,324 @@
//! Multi-agent picker navigation and labeling state for the TUI app.
//!
//! This module exists to keep the pure parts of multi-agent navigation out of [`crate::app::App`].
//! It owns the stable spawn-order cache used by the `/agent` picker, keyboard next/previous
//! navigation, and the contextual footer label for the thread currently being watched.
//!
//! Responsibilities here are intentionally narrow:
//! - remember picker entries and their first-seen order
//! - answer traversal questions like "what is the next thread?"
//! - derive user-facing picker/footer text from cached thread metadata
//!
//! Responsibilities that stay in `App`:
//! - discovering threads from the backend
//! - deciding which thread is currently displayed
//! - mutating UI state such as switching threads or updating the footer widget
//!
//! The key invariant is that traversal follows first-seen spawn order rather than thread-id sort
//! order. Once a thread id is observed it keeps its place in the cycle even if the entry is later
//! updated or marked closed.
use crate::multi_agents::AgentPickerThreadEntry;
use crate::multi_agents::format_agent_picker_item_name;
use crate::multi_agents::next_agent_shortcut;
use crate::multi_agents::previous_agent_shortcut;
use codex_protocol::ThreadId;
use ratatui::text::Span;
use std::collections::HashMap;
/// Small state container for multi-agent picker ordering and labeling.
///
/// `App` owns thread lifecycle and UI side effects. This type keeps the pure rules for stable
/// spawn-order traversal, picker copy, and active-agent labels together and separately testable.
///
/// The core invariant is that `order` records first-seen thread ids exactly once, while `threads`
/// stores the latest metadata for those ids. Mutation is intentionally funneled through `upsert`,
/// `mark_closed`, and `clear` so those two collections do not drift semantically even if they are
/// temporarily out of sync during teardown races.
#[derive(Debug, Default)]
pub(crate) struct AgentNavigationState {
/// Latest picker metadata for each tracked thread id.
threads: HashMap<ThreadId, AgentPickerThreadEntry>,
/// Stable first-seen traversal order for picker rows and keyboard cycling.
order: Vec<ThreadId>,
}
/// Direction of keyboard traversal through the stable picker order.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AgentNavigationDirection {
/// Move toward the entry that was seen earlier in spawn order, wrapping at the front.
Previous,
/// Move toward the entry that was seen later in spawn order, wrapping at the end.
Next,
}
impl AgentNavigationState {
/// Returns the cached picker entry for a specific thread id.
///
/// Callers use this when they already know which thread they care about and need the last
/// metadata captured for picker or footer rendering. If a caller assumes every tracked thread
/// must be present here, shutdown races can turn that assumption into a panic elsewhere, so
/// this stays optional.
pub(crate) fn get(&self, thread_id: &ThreadId) -> Option<&AgentPickerThreadEntry> {
self.threads.get(thread_id)
}
/// Returns whether the picker cache currently knows about any threads.
///
/// This is the cheapest way for `App` to decide whether opening the picker should show "No
/// agents available yet." rather than constructing picker rows from an empty state.
pub(crate) fn is_empty(&self) -> bool {
self.threads.is_empty()
}
/// Inserts or updates a picker entry while preserving first-seen traversal order.
///
/// The key invariant of this module is enforced here: a thread id is appended to `order` only
/// the first time it is seen. Later updates may change nickname, role, or closed state, but
/// they must not move the thread in the cycle or keyboard navigation would feel unstable.
pub(crate) fn upsert(
&mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
agent_role: Option<String>,
is_closed: bool,
) {
if !self.threads.contains_key(&thread_id) {
self.order.push(thread_id);
}
self.threads.insert(
thread_id,
AgentPickerThreadEntry {
agent_nickname,
agent_role,
is_closed,
},
);
}
/// Marks a thread as closed without removing it from the traversal cache.
///
/// Closed threads stay in the picker and in spawn order so users can still review them and so
/// next/previous navigation does not reshuffle around disappearing entries. If a caller "cleans
/// this up" by deleting the entry instead, wraparound navigation will silently change shape
/// mid-session.
pub(crate) fn mark_closed(&mut self, thread_id: ThreadId) {
if let Some(entry) = self.threads.get_mut(&thread_id) {
entry.is_closed = true;
} else {
self.upsert(thread_id, None, None, true);
}
}
/// Drops all cached picker state.
///
/// This is used when `App` tears down thread event state and needs the picker cache to return
/// to a pristine single-session state.
pub(crate) fn clear(&mut self) {
self.threads.clear();
self.order.clear();
}
/// Returns whether there is at least one tracked thread other than the primary one.
///
/// `App` uses this to decide whether the picker should be available even when the collaboration
/// feature flag is currently disabled, because already-existing sub-agent threads should remain
/// inspectable.
pub(crate) fn has_non_primary_thread(&self, primary_thread_id: Option<ThreadId>) -> bool {
self.threads
.keys()
.any(|thread_id| Some(*thread_id) != primary_thread_id)
}
/// Returns live picker rows in the same order users cycle through them.
///
/// The `order` vector is intentionally historical and may briefly contain thread ids that no
/// longer have cached metadata, so this filters through the map instead of assuming both
/// collections are perfectly synchronized.
pub(crate) fn ordered_threads(&self) -> Vec<(ThreadId, &AgentPickerThreadEntry)> {
self.order
.iter()
.filter_map(|thread_id| self.threads.get(thread_id).map(|entry| (*thread_id, entry)))
.collect()
}
/// Returns the adjacent thread id for keyboard navigation in stable spawn order.
///
/// The caller must pass the thread whose transcript is actually being shown to the user, not
/// just whichever thread bookkeeping most recently marked active. If the wrong current thread
/// is supplied, next/previous navigation will jump in a way that feels nondeterministic even
/// though the cache itself is correct.
pub(crate) fn adjacent_thread_id(
&self,
current_displayed_thread_id: Option<ThreadId>,
direction: AgentNavigationDirection,
) -> Option<ThreadId> {
let ordered_threads = self.ordered_threads();
if ordered_threads.len() < 2 {
return None;
}
let current_thread_id = current_displayed_thread_id?;
let current_idx = ordered_threads
.iter()
.position(|(thread_id, _)| *thread_id == current_thread_id)?;
let next_idx = match direction {
AgentNavigationDirection::Next => (current_idx + 1) % ordered_threads.len(),
AgentNavigationDirection::Previous => {
if current_idx == 0 {
ordered_threads.len() - 1
} else {
current_idx - 1
}
}
};
Some(ordered_threads[next_idx].0)
}
/// Derives the contextual footer label for the currently displayed thread.
///
/// This intentionally returns `None` until there is more than one tracked thread so
/// single-thread sessions do not waste footer space restating the obvious. When metadata for
/// the displayed thread is missing, the label falls back to the same generic naming rules used
/// by the picker.
pub(crate) fn active_agent_label(
&self,
current_displayed_thread_id: Option<ThreadId>,
primary_thread_id: Option<ThreadId>,
) -> Option<String> {
if self.threads.len() <= 1 {
return None;
}
let thread_id = current_displayed_thread_id?;
let is_primary = primary_thread_id == Some(thread_id);
Some(
self.threads
.get(&thread_id)
.map(|entry| {
format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
)
})
.unwrap_or_else(|| format_agent_picker_item_name(None, None, is_primary)),
)
}
/// Builds the `/agent` picker subtitle from the same canonical bindings used by key handling.
///
/// Keeping this text derived from the actual shortcut helpers prevents the picker copy from
/// drifting if the bindings ever change on one platform.
pub(crate) fn picker_subtitle() -> String {
let previous: Span<'static> = previous_agent_shortcut().into();
let next: Span<'static> = next_agent_shortcut().into();
format!(
"Select an agent to watch. {} previous, {} next.",
previous.content, next.content
)
}
#[cfg(test)]
/// Returns only the ordered thread ids for focused tests of traversal invariants.
///
/// This helper exists so tests can assert on ordering without embedding the full picker entry
/// payload in every expectation.
pub(crate) fn ordered_thread_ids(&self) -> Vec<ThreadId> {
self.ordered_threads()
.into_iter()
.map(|(thread_id, _)| thread_id)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn populated_state() -> (AgentNavigationState, ThreadId, ThreadId, ThreadId) {
let mut state = AgentNavigationState::default();
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread");
let first_agent_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread");
let second_agent_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread");
state.upsert(main_thread_id, None, None, false);
state.upsert(
first_agent_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
false,
);
state.upsert(
second_agent_id,
Some("Bob".to_string()),
Some("worker".to_string()),
false,
);
(state, main_thread_id, first_agent_id, second_agent_id)
}
#[test]
fn upsert_preserves_first_seen_order() {
let (mut state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
state.upsert(
first_agent_id,
Some("Robie".to_string()),
Some("worker".to_string()),
true,
);
assert_eq!(
state.ordered_thread_ids(),
vec![main_thread_id, first_agent_id, second_agent_id]
);
}
#[test]
fn adjacent_thread_id_wraps_in_spawn_order() {
let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
assert_eq!(
state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Next),
Some(main_thread_id)
);
assert_eq!(
state.adjacent_thread_id(Some(second_agent_id), AgentNavigationDirection::Previous),
Some(first_agent_id)
);
assert_eq!(
state.adjacent_thread_id(Some(main_thread_id), AgentNavigationDirection::Previous),
Some(second_agent_id)
);
}
#[test]
fn picker_subtitle_mentions_shortcuts() {
let previous: Span<'static> = previous_agent_shortcut().into();
let next: Span<'static> = next_agent_shortcut().into();
let subtitle = AgentNavigationState::picker_subtitle();
assert!(subtitle.contains(previous.content.as_ref()));
assert!(subtitle.contains(next.content.as_ref()));
}
#[test]
fn active_agent_label_tracks_current_thread() {
let (state, main_thread_id, first_agent_id, _) = populated_state();
assert_eq!(
state.active_agent_label(Some(first_agent_id), Some(main_thread_id)),
Some("Robie [explorer]".to_string())
);
assert_eq!(
state.active_agent_label(Some(main_thread_id), Some(main_thread_id)),
Some("Main [default]".to_string())
);
}
}
@@ -0,0 +1,613 @@
/*
This module holds the temporary adapter layer between the TUI and the app
server during the hybrid migration period.
For now, the TUI still owns its existing direct-core behavior, but startup
allocates a local in-process app server and drains its event stream. Keeping
the app-server-specific wiring here keeps that transitional logic out of the
main `app.rs` orchestration path.
As more TUI flows move onto the app-server surface directly, this adapter
should shrink and eventually disappear.
*/
use super::App;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AgentReasoningDeltaEvent;
use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::PlanDeltaEvent;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ThreadNameUpdatedEvent;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use serde_json::Value;
impl App {
pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
event: AppServerEvent,
) {
match event {
AppServerEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"app-server event consumer lagged; dropping ignored events"
);
}
AppServerEvent::ServerNotification(notification) => match notification {
ServerNotification::ServerRequestResolved(notification) => {
self.pending_app_server_requests
.resolve_notification(&notification.request_id);
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget.on_rate_limit_snapshot(Some(
app_server_rate_limit_snapshot_to_core(notification.rate_limits),
));
}
ServerNotification::AccountUpdated(notification) => {
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
notification.plan_type,
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
),
);
}
notification => {
if let Some((thread_id, events)) =
server_notification_thread_events(notification)
{
for event in events {
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!(
"failed to enqueue primary app-server server notification: {err}"
);
}
} else if let Err(err) =
self.enqueue_thread_event(thread_id, event).await
{
tracing::warn!(
"failed to enqueue app-server server notification for {thread_id}: {err}"
);
}
}
}
}
},
AppServerEvent::LegacyNotification(notification) => {
if let Some((thread_id, event)) = legacy_thread_event(notification.params) {
self.pending_app_server_requests.note_legacy_event(&event);
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!("failed to enqueue primary app-server event: {err}");
}
} else if let Err(err) = self.enqueue_thread_event(thread_id, event).await {
tracing::warn!(
"failed to enqueue app-server thread event for {thread_id}: {err}"
);
}
}
}
AppServerEvent::ServerRequest(request) => {
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
{
tracing::warn!(
request_id = ?unsupported.request_id,
message = unsupported.message,
"rejecting unsupported app-server request"
);
self.chat_widget
.add_error_message(unsupported.message.clone());
if let Err(err) = self
.reject_app_server_request(
app_server_client,
unsupported.request_id,
unsupported.message,
)
.await
{
tracing::warn!("{err}");
}
}
}
AppServerEvent::Disconnected { message } => {
tracing::warn!("app-server event stream disconnected: {message}");
self.chat_widget.add_error_message(message.clone());
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
reason: String,
) -> std::result::Result<(), String> {
app_server_client
.reject_server_request(
request_id,
JSONRPCErrorError {
code: -32000,
message: reason,
data: None,
},
)
.await
.map_err(|err| format!("failed to reject app-server request: {err}"))
}
}
fn legacy_thread_event(params: Option<Value>) -> Option<(ThreadId, Event)> {
let Value::Object(mut params) = params? else {
return None;
};
let thread_id = params
.remove("conversationId")
.and_then(|value| serde_json::from_value::<String>(value).ok())
.and_then(|value| ThreadId::from_string(&value).ok());
let event = serde_json::from_value::<Event>(Value::Object(params)).ok()?;
let thread_id = thread_id.or(match &event.msg {
EventMsg::SessionConfigured(session) => Some(session.session_id),
_ => None,
})?;
Some((thread_id, event))
}
fn server_notification_thread_events(
notification: ServerNotification,
) -> Option<(ThreadId, Vec<Event>)> {
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: token_usage_from_app_server(
notification.token_usage.total,
),
last_token_usage: token_usage_from_app_server(
notification.token_usage.last,
),
model_context_window: notification.token_usage.model_context_window,
}),
rate_limits: None,
}),
}],
)),
ServerNotification::Error(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::Error(ErrorEvent {
message: notification.error.message,
codex_error_info: notification
.error
.codex_error_info
.and_then(app_server_codex_error_info_to_core),
}),
}],
)),
ServerNotification::ThreadNameUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
thread_name: notification.thread_name,
}),
}],
)),
ServerNotification::TurnStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: notification.turn.id,
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
}),
}],
)),
ServerNotification::TurnCompleted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: notification.turn.id,
last_agent_message: None,
}),
}],
)),
ServerNotification::ItemStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(notification.item)?,
}),
}],
)),
ServerNotification::ItemCompleted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(notification.item)?,
}),
}],
)),
ServerNotification::AgentMessageDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::PlanDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::PlanDelta(PlanDeltaEvent {
thread_id: notification.thread_id,
turn_id: notification.turn_id,
item_id: notification.item_id,
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningSummaryTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ThreadRealtimeStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: notification.session_id,
}),
}],
)),
ServerNotification::ThreadRealtimeItemAdded(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(notification.item),
}),
}],
)),
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(notification.audio.into()),
}),
}],
)),
ServerNotification::ThreadRealtimeError(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(notification.message),
}),
}],
)),
ServerNotification::ThreadRealtimeClosed(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent {
reason: notification.reason,
}),
}],
)),
_ => None,
}
}
fn token_usage_from_app_server(
value: codex_app_server_protocol::TokenUsageBreakdown,
) -> TokenUsage {
TokenUsage {
input_tokens: value.input_tokens,
cached_input_tokens: value.cached_input_tokens,
output_tokens: value.output_tokens,
reasoning_output_tokens: value.reasoning_output_tokens,
total_tokens: value.total_tokens,
}
}
fn thread_item_to_core(item: ThreadItem) -> Option<TurnItem> {
match item {
ThreadItem::UserMessage { id, content } => Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
})),
ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
ThreadItem::Plan { id, text } => Some(TurnItem::Plan(PlanItem { id, text })),
ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
})),
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. } => {
tracing::debug!("ignoring unsupported app-server thread item in TUI adapter");
None
}
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => None,
}
}
fn app_server_codex_error_info_to_core(
value: codex_app_server_protocol::CodexErrorInfo,
) -> Option<codex_protocol::protocol::CodexErrorInfo> {
serde_json::from_value(serde_json::to_value(value).ok()?).ok()
}
#[cfg(test)]
mod tests {
use super::server_notification_thread_events;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::EventMsg;
use pretty_assertions::assert_eq;
#[test]
fn bridges_completed_agent_messages_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let item_id = "msg_123".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: item_id,
text: "Hello from your coding assistant.".to_string(),
phase: Some(MessagePhase::FinalAnswer),
},
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::ItemCompleted(completed) = &event.msg else {
panic!("expected item completed event");
};
assert_eq!(
completed.thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
assert_eq!(completed.turn_id, turn_id);
match &completed.item {
TurnItem::AgentMessage(AgentMessageItem { id, content, phase }) => {
assert_eq!(id, "msg_123");
let [AgentMessageContent::Text { text }] = content.as_slice() else {
panic!("expected a single text content item");
};
assert_eq!(text, "Hello from your coding assistant.");
assert_eq!(*phase, Some(MessagePhase::FinalAnswer));
}
_ => panic!("expected bridged agent message item"),
}
}
#[test]
fn bridges_turn_completion_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.clone(),
turn: Turn {
id: turn_id.clone(),
items: Vec::new(),
status: TurnStatus::Completed,
error: None,
},
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::TurnComplete(completed) = &event.msg else {
panic!("expected turn complete event");
};
assert_eq!(completed.turn_id, turn_id);
assert_eq!(completed.last_agent_message, None);
}
#[test]
fn bridges_text_deltas_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let (_, agent_events) = server_notification_thread_events(
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
thread_id: thread_id.clone(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Hello".to_string(),
}),
)
.expect("notification should bridge");
let [agent_event] = agent_events.as_slice() else {
panic!("expected one bridged agent delta event");
};
assert_eq!(agent_event.id, String::new());
let EventMsg::AgentMessageDelta(delta) = &agent_event.msg else {
panic!("expected bridged agent message delta");
};
assert_eq!(delta.delta, "Hello");
let (_, reasoning_events) = server_notification_thread_events(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id,
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Thinking".to_string(),
summary_index: 0,
}),
)
.expect("notification should bridge");
let [reasoning_event] = reasoning_events.as_slice() else {
panic!("expected one bridged reasoning delta event");
};
assert_eq!(reasoning_event.id, String::new());
let EventMsg::AgentReasoningDelta(delta) = &reasoning_event.msg else {
panic!("expected bridged reasoning delta");
};
assert_eq!(delta.delta, "Thinking");
}
}
@@ -0,0 +1,645 @@
use std::collections::HashMap;
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::GrantedPermissionProfile;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ReviewDecision;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct AppServerRequestResolution {
pub(super) request_id: AppServerRequestId,
pub(super) result: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct UnsupportedAppServerRequest {
pub(super) request_id: AppServerRequestId,
pub(super) message: String,
}
#[derive(Debug, Default)]
pub(super) struct PendingAppServerRequests {
exec_approvals: HashMap<String, AppServerRequestId>,
file_change_approvals: HashMap<String, AppServerRequestId>,
permissions_approvals: HashMap<String, AppServerRequestId>,
user_inputs: HashMap<String, AppServerRequestId>,
mcp_pending_by_matcher: HashMap<McpServerMatcher, AppServerRequestId>,
mcp_legacy_by_matcher: HashMap<McpServerMatcher, McpLegacyRequestKey>,
mcp_legacy_requests: HashMap<McpLegacyRequestKey, AppServerRequestId>,
}
impl PendingAppServerRequests {
pub(super) fn clear(&mut self) {
self.exec_approvals.clear();
self.file_change_approvals.clear();
self.permissions_approvals.clear();
self.user_inputs.clear();
self.mcp_pending_by_matcher.clear();
self.mcp_legacy_by_matcher.clear();
self.mcp_legacy_requests.clear();
}
pub(super) fn note_server_request(
&mut self,
request: &ServerRequest,
) -> Option<UnsupportedAppServerRequest> {
match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let approval_id = params
.approval_id
.clone()
.unwrap_or_else(|| params.item_id.clone());
self.exec_approvals.insert(approval_id, request_id.clone());
None
}
ServerRequest::FileChangeRequestApproval { request_id, params } => {
self.file_change_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::PermissionsRequestApproval { request_id, params } => {
self.permissions_approvals
.insert(params.item_id.clone(), request_id.clone());
None
}
ServerRequest::ToolRequestUserInput { request_id, params } => {
self.user_inputs
.insert(params.turn_id.clone(), request_id.clone());
None
}
ServerRequest::McpServerElicitationRequest { request_id, params } => {
let matcher = McpServerMatcher::from_v2(params);
if let Some(legacy_key) = self.mcp_legacy_by_matcher.remove(&matcher) {
self.mcp_legacy_requests
.insert(legacy_key, request_id.clone());
} else {
self.mcp_pending_by_matcher
.insert(matcher, request_id.clone());
}
None
}
ServerRequest::DynamicToolCall { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Dynamic tool calls are not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "ChatGPT auth token refresh is not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message:
"Legacy patch approval requests are not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ExecCommandApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message:
"Legacy command approval requests are not available in app-server TUI yet."
.to_string(),
})
}
}
}
pub(super) fn note_legacy_event(&mut self, event: &Event) {
let EventMsg::ElicitationRequest(request) = &event.msg else {
return;
};
let matcher = McpServerMatcher::from_core(
&request.server_name,
request.turn_id.as_deref(),
&request.request,
);
let legacy_key = McpLegacyRequestKey {
server_name: request.server_name.clone(),
request_id: request.id.clone(),
};
if let Some(request_id) = self.mcp_pending_by_matcher.remove(&matcher) {
self.mcp_legacy_requests.insert(legacy_key, request_id);
} else {
self.mcp_legacy_by_matcher.insert(matcher, legacy_key);
}
}
pub(super) fn take_resolution<T>(
&mut self,
op: T,
) -> Result<Option<AppServerRequestResolution>, String>
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
let resolution = match op.view() {
AppCommandView::ExecApproval { id, decision, .. } => self
.exec_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(CommandExecutionRequestApprovalResponse {
decision: decision.clone().into(),
})
.map_err(|err| {
format!("failed to serialize command execution approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::PatchApproval { id, decision } => self
.file_change_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(FileChangeRequestApprovalResponse {
decision: file_change_decision(decision)?,
})
.map_err(|err| {
format!("failed to serialize file change approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::RequestPermissionsResponse { id, response } => self
.permissions_approvals
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(PermissionsRequestApprovalResponse {
permissions: serde_json::from_value::<GrantedPermissionProfile>(
serde_json::to_value(&response.permissions).map_err(|err| {
format!("failed to encode granted permissions: {err}")
})?,
)
.map_err(|err| {
format!("failed to decode granted permissions for app-server: {err}")
})?,
scope: response.scope.into(),
})
.map_err(|err| {
format!("failed to serialize permissions approval response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::UserInputAnswer { id, response } => self
.user_inputs
.remove(id)
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(
serde_json::from_value::<ToolRequestUserInputResponse>(
serde_json::to_value(response).map_err(|err| {
format!("failed to encode request_user_input response: {err}")
})?,
)
.map_err(|err| {
format!(
"failed to decode request_user_input response for app-server: {err}"
)
})?,
)
.map_err(|err| {
format!("failed to serialize request_user_input response: {err}")
})?,
})
})
.transpose()?,
AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => self
.mcp_legacy_requests
.remove(&McpLegacyRequestKey {
server_name: server_name.to_string(),
request_id: request_id.clone(),
})
.map(|request_id| {
Ok::<AppServerRequestResolution, String>(AppServerRequestResolution {
request_id,
result: serde_json::to_value(McpServerElicitationRequestResponse {
action: match decision {
codex_protocol::approvals::ElicitationAction::Accept => {
McpServerElicitationAction::Accept
}
codex_protocol::approvals::ElicitationAction::Decline => {
McpServerElicitationAction::Decline
}
codex_protocol::approvals::ElicitationAction::Cancel => {
McpServerElicitationAction::Cancel
}
},
content: content.clone(),
meta: meta.clone(),
})
.map_err(|err| {
format!("failed to serialize MCP elicitation response: {err}")
})?,
})
})
.transpose()?,
_ => None,
};
Ok(resolution)
}
pub(super) fn resolve_notification(&mut self, request_id: &AppServerRequestId) {
self.exec_approvals.retain(|_, value| value != request_id);
self.file_change_approvals
.retain(|_, value| value != request_id);
self.permissions_approvals
.retain(|_, value| value != request_id);
self.user_inputs.retain(|_, value| value != request_id);
self.mcp_pending_by_matcher
.retain(|_, value| value != request_id);
self.mcp_legacy_requests
.retain(|_, value| value != request_id);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct McpServerMatcher {
server_name: String,
turn_id: Option<String>,
request: String,
}
impl McpServerMatcher {
fn from_v2(params: &McpServerElicitationRequestParams) -> Self {
Self {
server_name: params.server_name.clone(),
turn_id: params.turn_id.clone(),
request: serde_json::to_string(
&serde_json::to_value(&params.request).unwrap_or(serde_json::Value::Null),
)
.unwrap_or_else(|_| "null".to_string()),
}
}
fn from_core(server_name: &str, turn_id: Option<&str>, request: &ElicitationRequest) -> Self {
let request = match request {
ElicitationRequest::Form {
meta,
message,
requested_schema,
} => serde_json::to_string(&serde_json::json!({
"mode": "form",
"_meta": meta,
"message": message,
"requestedSchema": requested_schema,
}))
.unwrap_or_else(|_| "null".to_string()),
ElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} => serde_json::to_string(&serde_json::json!({
"mode": "url",
"_meta": meta,
"message": message,
"url": url,
"elicitationId": elicitation_id,
}))
.unwrap_or_else(|_| "null".to_string()),
};
Self {
server_name: server_name.to_string(),
turn_id: turn_id.map(str::to_string),
request,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct McpLegacyRequestKey {
server_name: String,
request_id: McpRequestId,
}
fn file_change_decision(decision: &ReviewDecision) -> Result<FileChangeApprovalDecision, String> {
match decision {
ReviewDecision::Approved => Ok(FileChangeApprovalDecision::Accept),
ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession),
ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline),
ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel),
ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
Err("execpolicy amendment is not a valid file change approval decision".to_string())
}
ReviewDecision::NetworkPolicyAmendment { .. } => {
Err("network policy amendment is not a valid file change approval decision".to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::PendingAppServerRequests;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_app_server_protocol::PermissionGrantScope;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputResponse;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn resolves_exec_approval_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
let request = ServerRequest::CommandExecutionRequestApproval {
request_id: AppServerRequestId::Integer(41),
params: CommandExecutionRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
reason: None,
network_approval_context: None,
command: Some("ls".to_string()),
cwd: None,
command_actions: None,
additional_permissions: None,
skill_metadata: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
};
assert_eq!(pending.note_server_request(&request), None);
let resolution = pending
.take_resolution(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.expect("resolution should serialize")
.expect("request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(41));
assert_eq!(resolution.result, json!({ "decision": "accept" }));
}
#[test]
fn resolves_permissions_and_user_input_through_app_server_request_id() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::PermissionsRequestApproval {
request_id: AppServerRequestId::Integer(7),
params: PermissionsRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "perm-1".to_string(),
reason: None,
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
},
}),
None
);
assert_eq!(
pending.note_server_request(&ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(8),
params: ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-2".to_string(),
item_id: "tool-1".to_string(),
questions: Vec::new(),
},
}),
None
);
let permissions = pending
.take_resolution(&Op::RequestPermissionsResponse {
id: "perm-1".to_string(),
response: codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: codex_protocol::request_permissions::PermissionGrantScope::Session,
},
})
.expect("permissions response should serialize")
.expect("permissions request should be pending");
assert_eq!(permissions.request_id, AppServerRequestId::Integer(7));
assert_eq!(
serde_json::from_value::<PermissionsRequestApprovalResponse>(permissions.result)
.expect("permissions response should decode"),
PermissionsRequestApprovalResponse {
permissions: serde_json::from_value(json!({
"network": { "enabled": null }
}))
.expect("valid permissions"),
scope: PermissionGrantScope::Session,
}
);
let user_input = pending
.take_resolution(&Op::UserInputAnswer {
id: "turn-2".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
codex_protocol::request_user_input::RequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
},
})
.expect("user input response should serialize")
.expect("user input request should be pending");
assert_eq!(user_input.request_id, AppServerRequestId::Integer(8));
assert_eq!(
serde_json::from_value::<ToolRequestUserInputResponse>(user_input.result)
.expect("user input response should decode"),
ToolRequestUserInputResponse {
answers: std::iter::once((
"question".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["yes".to_string()],
},
))
.collect(),
}
);
}
#[test]
fn correlates_mcp_elicitation_between_legacy_event_and_server_request() {
let mut pending = PendingAppServerRequests::default();
pending.note_legacy_event(&Event {
id: "event-1".to_string(),
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "example".to_string(),
id: McpRequestId::String("mcp-1".to_string()),
request: ElicitationRequest::Form {
meta: None,
message: "Need input".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
}),
});
assert_eq!(
pending.note_server_request(&ServerRequest::McpServerElicitationRequest {
request_id: AppServerRequestId::Integer(12),
params: McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: "example".to_string(),
request: McpServerElicitationRequest::Form {
meta: None,
message: "Need input".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
},
}),
None
);
let resolution = pending
.take_resolution(&Op::ResolveElicitation {
server_name: "example".to_string(),
request_id: McpRequestId::String("mcp-1".to_string()),
decision: ElicitationAction::Accept,
content: Some(json!({ "answer": "yes" })),
meta: Some(json!({ "source": "tui" })),
})
.expect("elicitation response should serialize")
.expect("elicitation request should be pending");
assert_eq!(resolution.request_id, AppServerRequestId::Integer(12));
assert_eq!(
resolution.result,
json!({
"action": "accept",
"content": { "answer": "yes" },
"_meta": { "source": "tui" }
})
);
}
#[test]
fn rejects_dynamic_tool_calls_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
let unsupported = pending
.note_server_request(&ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
tool: "tool".to_string(),
arguments: json!({}),
},
})
.expect("dynamic tool calls should be rejected");
assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99));
assert_eq!(
unsupported.message,
"Dynamic tool calls are not available in app-server TUI yet."
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::FileChangeRequestApproval {
request_id: AppServerRequestId::Integer(13),
params: FileChangeRequestApprovalParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "patch-1".to_string(),
reason: None,
grant_root: None,
},
}),
None
);
let error = pending
.take_resolution(&Op::PatchApproval {
id: "patch-1".to_string(),
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string(),
"hi".to_string(),
]),
},
})
.expect_err("invalid patch decision should fail");
assert_eq!(
error,
"execpolicy amendment is not a valid file change approval decision"
);
}
}
@@ -0,0 +1,733 @@
use crate::app_command::AppCommand;
use crate::app_command::AppCommandView;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ElicitationRequestKey {
server_name: String,
request_id: codex_protocol::mcp::RequestId,
}
impl ElicitationRequestKey {
fn new(server_name: String, request_id: codex_protocol::mcp::RequestId) -> Self {
Self {
server_name,
request_id,
}
}
}
#[derive(Debug, Default)]
// Tracks which interactive prompts are still unresolved in the thread-event buffer.
//
// Thread snapshots are replayed when switching threads/agents. Most events should replay
// verbatim, but interactive prompts (approvals, request_user_input, MCP elicitations) must
// only replay if they are still pending. This state is updated from:
// - inbound events (`note_event`)
// - outbound ops that resolve a prompt (`note_outbound_op`)
// - buffer eviction (`note_evicted_event`)
//
// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and
// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied
// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts
// in FIFO order for a shared `turn_id`.
pub(super) struct PendingInteractiveReplayState {
exec_approval_call_ids: HashSet<String>,
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
patch_approval_call_ids: HashSet<String>,
patch_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
elicitation_requests: HashSet<ElicitationRequestKey>,
request_permissions_call_ids: HashSet<String>,
request_permissions_call_ids_by_turn_id: HashMap<String, Vec<String>>,
request_user_input_call_ids: HashSet<String>,
request_user_input_call_ids_by_turn_id: HashMap<String, Vec<String>>,
}
impl PendingInteractiveReplayState {
pub(super) fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
matches!(
&event.msg,
EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::RequestPermissions(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::PatchApplyBegin(_)
| EventMsg::TurnComplete(_)
| EventMsg::TurnAborted(_)
| EventMsg::ShutdownComplete
)
}
pub(super) fn op_can_change_state<T>(op: T) -> bool
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
matches!(
op.view(),
AppCommandView::ExecApproval { .. }
| AppCommandView::PatchApproval { .. }
| AppCommandView::ResolveElicitation { .. }
| AppCommandView::RequestPermissionsResponse { .. }
| AppCommandView::UserInputAnswer { .. }
| AppCommandView::Shutdown
)
}
pub(super) fn note_outbound_op<T>(&mut self, op: T)
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
match op.view() {
AppCommandView::ExecApproval { id, turn_id, .. } => {
self.exec_approval_call_ids.remove(id);
if let Some(turn_id) = turn_id {
Self::remove_call_id_from_turn_map_entry(
&mut self.exec_approval_call_ids_by_turn_id,
turn_id,
id,
);
}
}
AppCommandView::PatchApproval { id, .. } => {
self.patch_approval_call_ids.remove(id);
Self::remove_call_id_from_turn_map(
&mut self.patch_approval_call_ids_by_turn_id,
id,
);
}
AppCommandView::ResolveElicitation {
server_name,
request_id,
..
} => {
self.elicitation_requests
.remove(&ElicitationRequestKey::new(
server_name.to_string(),
request_id.clone(),
));
}
AppCommandView::RequestPermissionsResponse { id, .. } => {
self.request_permissions_call_ids.remove(id);
Self::remove_call_id_from_turn_map(
&mut self.request_permissions_call_ids_by_turn_id,
id,
);
}
// `Op::UserInputAnswer` identifies the turn, not the prompt call_id. The UI
// answers queued prompts for the same turn in FIFO order, so remove the oldest
// queued call_id for that turn.
AppCommandView::UserInputAnswer { id, .. } => {
let mut remove_turn_entry = false;
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.get_mut(id) {
if !call_ids.is_empty() {
let call_id = call_ids.remove(0);
self.request_user_input_call_ids.remove(&call_id);
}
if call_ids.is_empty() {
remove_turn_entry = true;
}
}
if remove_turn_entry {
self.request_user_input_call_ids_by_turn_id.remove(id);
}
}
AppCommandView::Shutdown => self.clear(),
_ => {}
}
}
pub(super) fn note_event(&mut self, event: &Event) {
match &event.msg {
EventMsg::ExecApprovalRequest(ev) => {
let approval_id = ev.effective_approval_id();
self.exec_approval_call_ids.insert(approval_id.clone());
self.exec_approval_call_ids_by_turn_id
.entry(ev.turn_id.clone())
.or_default()
.push(approval_id);
}
EventMsg::ExecCommandBegin(ev) => {
self.exec_approval_call_ids.remove(&ev.call_id);
Self::remove_call_id_from_turn_map(
&mut self.exec_approval_call_ids_by_turn_id,
&ev.call_id,
);
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.patch_approval_call_ids.insert(ev.call_id.clone());
self.patch_approval_call_ids_by_turn_id
.entry(ev.turn_id.clone())
.or_default()
.push(ev.call_id.clone());
}
EventMsg::PatchApplyBegin(ev) => {
self.patch_approval_call_ids.remove(&ev.call_id);
Self::remove_call_id_from_turn_map(
&mut self.patch_approval_call_ids_by_turn_id,
&ev.call_id,
);
}
EventMsg::ElicitationRequest(ev) => {
self.elicitation_requests.insert(ElicitationRequestKey::new(
ev.server_name.clone(),
ev.id.clone(),
));
}
EventMsg::RequestUserInput(ev) => {
self.request_user_input_call_ids.insert(ev.call_id.clone());
self.request_user_input_call_ids_by_turn_id
.entry(ev.turn_id.clone())
.or_default()
.push(ev.call_id.clone());
}
EventMsg::RequestPermissions(ev) => {
self.request_permissions_call_ids.insert(ev.call_id.clone());
self.request_permissions_call_ids_by_turn_id
.entry(ev.turn_id.clone())
.or_default()
.push(ev.call_id.clone());
}
// A turn ending (normally or aborted/replaced) invalidates any unresolved
// turn-scoped approvals, permission prompts, and request_user_input prompts.
EventMsg::TurnComplete(ev) => {
self.clear_exec_approval_turn(&ev.turn_id);
self.clear_patch_approval_turn(&ev.turn_id);
self.clear_request_permissions_turn(&ev.turn_id);
self.clear_request_user_input_turn(&ev.turn_id);
}
EventMsg::TurnAborted(ev) => {
if let Some(turn_id) = &ev.turn_id {
self.clear_exec_approval_turn(turn_id);
self.clear_patch_approval_turn(turn_id);
self.clear_request_permissions_turn(turn_id);
self.clear_request_user_input_turn(turn_id);
}
}
EventMsg::ShutdownComplete => self.clear(),
_ => {}
}
}
pub(super) fn note_evicted_event(&mut self, event: &Event) {
match &event.msg {
EventMsg::ExecApprovalRequest(ev) => {
let approval_id = ev.effective_approval_id();
self.exec_approval_call_ids.remove(&approval_id);
Self::remove_call_id_from_turn_map_entry(
&mut self.exec_approval_call_ids_by_turn_id,
&ev.turn_id,
&approval_id,
);
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.patch_approval_call_ids.remove(&ev.call_id);
Self::remove_call_id_from_turn_map_entry(
&mut self.patch_approval_call_ids_by_turn_id,
&ev.turn_id,
&ev.call_id,
);
}
EventMsg::ElicitationRequest(ev) => {
self.elicitation_requests
.remove(&ElicitationRequestKey::new(
ev.server_name.clone(),
ev.id.clone(),
));
}
EventMsg::RequestUserInput(ev) => {
self.request_user_input_call_ids.remove(&ev.call_id);
let mut remove_turn_entry = false;
if let Some(call_ids) = self
.request_user_input_call_ids_by_turn_id
.get_mut(&ev.turn_id)
{
call_ids.retain(|call_id| call_id != &ev.call_id);
if call_ids.is_empty() {
remove_turn_entry = true;
}
}
if remove_turn_entry {
self.request_user_input_call_ids_by_turn_id
.remove(&ev.turn_id);
}
}
EventMsg::RequestPermissions(ev) => {
self.request_permissions_call_ids.remove(&ev.call_id);
let mut remove_turn_entry = false;
if let Some(call_ids) = self
.request_permissions_call_ids_by_turn_id
.get_mut(&ev.turn_id)
{
call_ids.retain(|call_id| call_id != &ev.call_id);
if call_ids.is_empty() {
remove_turn_entry = true;
}
}
if remove_turn_entry {
self.request_permissions_call_ids_by_turn_id
.remove(&ev.turn_id);
}
}
_ => {}
}
}
pub(super) fn should_replay_snapshot_event(&self, event: &Event) -> bool {
match &event.msg {
EventMsg::ExecApprovalRequest(ev) => self
.exec_approval_call_ids
.contains(&ev.effective_approval_id()),
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.patch_approval_call_ids.contains(&ev.call_id)
}
EventMsg::ElicitationRequest(ev) => {
self.elicitation_requests
.contains(&ElicitationRequestKey::new(
ev.server_name.clone(),
ev.id.clone(),
))
}
EventMsg::RequestUserInput(ev) => {
self.request_user_input_call_ids.contains(&ev.call_id)
}
EventMsg::RequestPermissions(ev) => {
self.request_permissions_call_ids.contains(&ev.call_id)
}
_ => true,
}
}
pub(super) fn has_pending_thread_approvals(&self) -> bool {
!self.exec_approval_call_ids.is_empty()
|| !self.patch_approval_call_ids.is_empty()
|| !self.elicitation_requests.is_empty()
|| !self.request_permissions_call_ids.is_empty()
}
fn clear_request_user_input_turn(&mut self, turn_id: &str) {
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.remove(turn_id) {
for call_id in call_ids {
self.request_user_input_call_ids.remove(&call_id);
}
}
}
fn clear_request_permissions_turn(&mut self, turn_id: &str) {
if let Some(call_ids) = self.request_permissions_call_ids_by_turn_id.remove(turn_id) {
for call_id in call_ids {
self.request_permissions_call_ids.remove(&call_id);
}
}
}
fn clear_exec_approval_turn(&mut self, turn_id: &str) {
if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) {
for call_id in call_ids {
self.exec_approval_call_ids.remove(&call_id);
}
}
}
fn clear_patch_approval_turn(&mut self, turn_id: &str) {
if let Some(call_ids) = self.patch_approval_call_ids_by_turn_id.remove(turn_id) {
for call_id in call_ids {
self.patch_approval_call_ids.remove(&call_id);
}
}
}
fn remove_call_id_from_turn_map(
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
call_id: &str,
) {
call_ids_by_turn_id.retain(|_, call_ids| {
call_ids.retain(|queued_call_id| queued_call_id != call_id);
!call_ids.is_empty()
});
}
fn remove_call_id_from_turn_map_entry(
call_ids_by_turn_id: &mut HashMap<String, Vec<String>>,
turn_id: &str,
call_id: &str,
) {
let mut remove_turn_entry = false;
if let Some(call_ids) = call_ids_by_turn_id.get_mut(turn_id) {
call_ids.retain(|queued_call_id| queued_call_id != call_id);
if call_ids.is_empty() {
remove_turn_entry = true;
}
}
if remove_turn_entry {
call_ids_by_turn_id.remove(turn_id);
}
}
fn clear(&mut self) {
self.exec_approval_call_ids.clear();
self.exec_approval_call_ids_by_turn_id.clear();
self.patch_approval_call_ids.clear();
self.patch_approval_call_ids_by_turn_id.clear();
self.elicitation_requests.clear();
self.request_permissions_call_ids.clear();
self.request_permissions_call_ids_by_turn_id.clear();
self.request_user_input_call_ids.clear();
self.request_user_input_call_ids_by_turn_id.clear();
}
}
#[cfg(test)]
mod tests {
use super::super::ThreadEventStore;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::TurnAbortReason;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn thread_event_snapshot_keeps_pending_request_user_input() {
let mut store = ThreadEventStore::new(8);
let request = Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
};
store.push_event(request);
let snapshot = store.snapshot();
assert_eq!(snapshot.events.len(), 1);
assert!(matches!(
snapshot.events.first().map(|event| &event.msg),
Some(EventMsg::RequestUserInput(_))
));
}
#[test]
fn thread_event_snapshot_drops_resolved_request_user_input_after_user_answer() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
store.note_outbound_op(&Op::UserInputAnswer {
id: "turn-1".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: HashMap::new(),
},
});
let snapshot = store.snapshot();
assert!(
snapshot.events.is_empty(),
"resolved request_user_input prompt should not replay on thread switch"
);
}
#[test]
fn thread_event_snapshot_drops_resolved_exec_approval_after_outbound_approval_id() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: Some("approval-1".to_string()),
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
skill_metadata: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
});
store.note_outbound_op(&Op::ExecApproval {
id: "approval-1".to_string(),
turn_id: Some("turn-1".to_string()),
decision: codex_protocol::protocol::ReviewDecision::Approved,
});
let snapshot = store.snapshot();
assert!(
snapshot.events.is_empty(),
"resolved exec approval prompt should not replay on thread switch"
);
}
#[test]
fn thread_event_snapshot_drops_answered_request_user_input_for_multi_prompt_turn() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
store.note_outbound_op(&Op::UserInputAnswer {
id: "turn-1".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: HashMap::new(),
},
});
store.push_event(Event {
id: "ev-2".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
let snapshot = store.snapshot();
assert_eq!(snapshot.events.len(), 1);
assert!(matches!(
snapshot.events.first().map(|event| &event.msg),
Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2"
));
}
#[test]
fn thread_event_snapshot_keeps_newer_request_user_input_pending_when_same_turn_has_queue() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
store.push_event(Event {
id: "ev-2".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-2".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
store.note_outbound_op(&Op::UserInputAnswer {
id: "turn-1".to_string(),
response: codex_protocol::request_user_input::RequestUserInputResponse {
answers: HashMap::new(),
},
});
let snapshot = store.snapshot();
assert_eq!(snapshot.events.len(), 1);
assert!(matches!(
snapshot.events.first().map(|event| &event.msg),
Some(EventMsg::RequestUserInput(ev)) if ev.call_id == "call-2"
));
}
#[test]
fn thread_event_snapshot_drops_resolved_patch_approval_after_outbound_approval() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ApplyPatchApprovalRequest(
codex_protocol::protocol::ApplyPatchApprovalRequestEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
changes: HashMap::new(),
reason: None,
grant_root: None,
},
),
});
store.note_outbound_op(&Op::PatchApproval {
id: "call-1".to_string(),
decision: codex_protocol::protocol::ReviewDecision::Approved,
});
let snapshot = store.snapshot();
assert!(
snapshot.events.is_empty(),
"resolved patch approval prompt should not replay on thread switch"
);
}
#[test]
fn thread_event_snapshot_drops_pending_approvals_when_turn_aborts() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "exec-call-1".to_string(),
approval_id: Some("approval-1".to_string()),
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
skill_metadata: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
});
store.push_event(Event {
id: "ev-2".to_string(),
msg: EventMsg::ApplyPatchApprovalRequest(
codex_protocol::protocol::ApplyPatchApprovalRequestEvent {
call_id: "patch-call-1".to_string(),
turn_id: "turn-1".to_string(),
changes: HashMap::new(),
reason: None,
grant_root: None,
},
),
});
store.push_event(Event {
id: "ev-3".to_string(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Replaced,
}),
});
let snapshot = store.snapshot();
assert!(snapshot.events.iter().all(|event| {
!matches!(
&event.msg,
EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_)
)
}));
}
#[test]
fn thread_event_snapshot_drops_resolved_elicitation_after_outbound_resolution() {
let mut store = ThreadEventStore::new(8);
let request_id = codex_protocol::mcp::RequestId::String("request-1".to_string());
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ElicitationRequest(codex_protocol::approvals::ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "server-1".to_string(),
id: request_id.clone(),
request: codex_protocol::approvals::ElicitationRequest::Form {
meta: None,
message: "Please confirm".to_string(),
requested_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
}),
});
store.note_outbound_op(&Op::ResolveElicitation {
server_name: "server-1".to_string(),
request_id,
decision: codex_protocol::approvals::ElicitationAction::Accept,
content: None,
meta: None,
});
let snapshot = store.snapshot();
assert!(
snapshot.events.is_empty(),
"resolved elicitation prompt should not replay on thread switch"
);
}
#[test]
fn thread_event_store_reports_pending_thread_approvals() {
let mut store = ThreadEventStore::new(8);
assert_eq!(store.has_pending_thread_approvals(), false);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
skill_metadata: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
});
assert_eq!(store.has_pending_thread_approvals(), true);
store.note_outbound_op(&Op::ExecApproval {
id: "call-1".to_string(),
turn_id: Some("turn-1".to_string()),
decision: codex_protocol::protocol::ReviewDecision::Approved,
});
assert_eq!(store.has_pending_thread_approvals(), false);
}
#[test]
fn request_user_input_does_not_count_as_pending_thread_approval() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
assert_eq!(store.has_pending_thread_approvals(), false);
}
}
@@ -0,0 +1,838 @@
//! Backtracking and transcript overlay event routing.
//!
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
//! mediates a key rendering boundary for the transcript overlay.
//!
//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing
//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to
//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI
//! state diverging from the agent if a rollback fails or targets a different thread.
//!
//! Backtrack operates as a small state machine:
//! - The first `Esc` in the main view "primes" the feature and captures a base thread id.
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message.
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard.
//! - On `EventMsg::ThreadRolledBack`, we either finish an in-flight backtrack request or queue a
//! rollback trim so it runs in event order with transcript inserts.
//!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
//! tail derived from the current in-flight `ChatWidget.active_cell`.
//!
//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by
//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into
//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects
//! both committed history and in-flight activity without changing flush or coalescing behavior.
use std::any::TypeId;
use std::path::PathBuf;
use std::sync::Arc;
use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_protocol::ThreadId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::user_input::TextElement;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
/// Aggregates all backtrack-related state used by the App.
#[derive(Default)]
pub(crate) struct BacktrackState {
/// True when Esc has primed backtrack mode in the main view.
pub(crate) primed: bool,
/// Session id of the base thread to rollback.
///
/// If the current thread changes, backtrack selections become invalid and must be ignored.
pub(crate) base_id: Option<ThreadId>,
/// Index of the currently highlighted user message.
///
/// This is an index into the filtered "user messages since the last session start" view,
/// not an index into `transcript_cells`. `usize::MAX` indicates "no selection".
pub(crate) nth_user_message: usize,
/// True when the transcript overlay is showing a backtrack preview.
pub(crate) overlay_preview_active: bool,
/// Pending rollback request awaiting confirmation from core.
///
/// This acts as a guardrail: once we request a rollback, we block additional backtrack
/// submissions until core responds with either a success or failure event.
pub(crate) pending_rollback: Option<PendingBacktrackRollback>,
}
/// A user-visible backtrack choice that can be confirmed into a rollback request.
#[derive(Debug, Clone)]
pub(crate) struct BacktrackSelection {
/// The selected user message, counted from the most recent session start.
///
/// This value is used both to compute the rollback depth and to trim the local transcript
/// after core confirms the rollback.
pub(crate) nth_user_message: usize,
/// Composer prefill derived from the selected user message.
///
/// This is applied immediately on selection confirmation; if the rollback fails, the prefill
/// remains as a convenience so the user can retry or edit.
pub(crate) prefill: String,
/// Text elements associated with the selected user message.
pub(crate) text_elements: Vec<TextElement>,
/// Local image paths associated with the selected user message.
pub(crate) local_image_paths: Vec<PathBuf>,
/// Remote image URLs associated with the selected user message.
pub(crate) remote_image_urls: Vec<String>,
}
/// An in-flight rollback requested from core.
///
/// We keep enough information to apply the corresponding local trim only if the response targets
/// the same active thread we issued the request for.
#[derive(Debug, Clone)]
pub(crate) struct PendingBacktrackRollback {
pub(crate) selection: BacktrackSelection,
pub(crate) thread_id: Option<ThreadId>,
}
impl App {
/// Route overlay events while the transcript overlay is active.
///
/// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter
/// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the
/// overlay.
pub(crate) async fn handle_backtrack_overlay_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
if self.backtrack.overlay_preview_active {
match event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack_forward(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => {
self.overlay_confirm_backtrack(tui);
Ok(true)
}
// Catchall: forward any other events to the overlay widget.
_ => {
self.overlay_forward_event(tui, event)?;
Ok(true)
}
}
} else if let TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = event
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
self.begin_overlay_backtrack_preview(tui);
Ok(true)
} else {
// Not in backtrack mode: forward events to the overlay widget.
self.overlay_forward_event(tui, event)?;
Ok(true)
}
}
/// Handle global Esc presses for backtracking when no overlay is present.
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
if !self.chat_widget.composer_is_empty() {
return;
}
if !self.backtrack.primed {
self.prime_backtrack();
} else if self.overlay.is_none() {
self.open_backtrack_preview(tui);
} else if self.backtrack.overlay_preview_active {
self.step_backtrack_and_highlight(tui);
}
}
/// Stage a backtrack and request thread history from the agent.
///
/// We send the rollback request immediately, but we only mutate the transcript after core
/// confirms success so the UI cannot get ahead of the actual thread state.
///
/// The composer prefill is applied immediately as a UX convenience; it does not imply that
/// core has accepted the rollback.
pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {
let user_total = user_count(&self.transcript_cells);
if user_total == 0 {
return;
}
if self.backtrack.pending_rollback.is_some() {
self.chat_widget
.add_error_message("Backtrack rollback already in progress.".to_string());
return;
}
let num_turns = user_total.saturating_sub(selection.nth_user_message);
let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);
if num_turns == 0 {
return;
}
let prefill = selection.prefill.clone();
let text_elements = selection.text_elements.clone();
let local_image_paths = selection.local_image_paths.clone();
let remote_image_urls = selection.remote_image_urls.clone();
let has_remote_image_urls = !remote_image_urls.is_empty();
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
selection,
thread_id: self.chat_widget.thread_id(),
});
self.chat_widget
.submit_op(AppCommand::thread_rollback(num_turns));
self.chat_widget.set_remote_image_urls(remote_image_urls);
if !prefill.is_empty()
|| !text_elements.is_empty()
|| !local_image_paths.is_empty()
|| has_remote_image_urls
{
self.chat_widget
.set_composer_text(prefill, text_elements, local_image_paths);
}
}
/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
/// Close transcript overlay and restore normal UI.
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.leave_alt_screen();
let was_backtrack = self.backtrack.overlay_preview_active;
if !self.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);
tui.insert_history_lines(lines);
}
self.overlay = None;
self.backtrack.overlay_preview_active = false;
if was_backtrack {
// Ensure backtrack state is fully reset when overlay closes (e.g. via 'q').
self.reset_backtrack_state();
}
}
/// Re-render the full transcript into the terminal scrollback in one call.
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
for cell in &self.transcript_cells {
tui.insert_history_lines(cell.display_lines(width));
}
}
}
/// Initialize backtrack state and show composer hint.
fn prime_backtrack(&mut self) {
self.backtrack.primed = true;
self.backtrack.nth_user_message = usize::MAX;
self.backtrack.base_id = self.chat_widget.thread_id();
self.chat_widget.show_esc_backtrack_hint();
}
/// Open overlay and begin backtrack preview flow (first step + highlight).
fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) {
self.open_transcript_overlay(tui);
self.backtrack.overlay_preview_active = true;
// Composer is hidden by overlay; clear its hint.
self.chat_widget.clear_esc_backtrack_hint();
self.step_backtrack_and_highlight(tui);
}
/// When overlay is already open, begin preview mode and select latest user message.
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.thread_id();
self.backtrack.overlay_preview_active = true;
let count = user_count(&self.transcript_cells);
if let Some(last) = count.checked_sub(1) {
self.apply_backtrack_selection_internal(last);
}
tui.frame_requester().schedule_frame();
}
/// Step selection to the next older user message and update overlay.
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
if count == 0 {
return;
}
let last_index = count.saturating_sub(1);
let next_selection = if self.backtrack.nth_user_message == usize::MAX {
last_index
} else if self.backtrack.nth_user_message == 0 {
0
} else {
self.backtrack
.nth_user_message
.saturating_sub(1)
.min(last_index)
};
self.apply_backtrack_selection_internal(next_selection);
tui.frame_requester().schedule_frame();
}
/// Step selection to the next newer user message and update overlay.
fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
if count == 0 {
return;
}
let last_index = count.saturating_sub(1);
let next_selection = if self.backtrack.nth_user_message == usize::MAX {
last_index
} else {
self.backtrack
.nth_user_message
.saturating_add(1)
.min(last_index)
};
self.apply_backtrack_selection_internal(next_selection);
tui.frame_requester().schedule_frame();
}
/// Apply a computed backtrack selection to the overlay and internal counter.
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
self.backtrack.nth_user_message = nth_user_message;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.set_highlight_cell(Some(cell_idx));
}
} else {
self.backtrack.nth_user_message = usize::MAX;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.set_highlight_cell(None);
}
}
}
/// Forwards an event to the overlay and closes it if done.
///
/// The transcript overlay draw path is special because the overlay should match the main
/// viewport while the active cell is still streaming or mutating.
///
/// `TranscriptOverlay` owns committed transcript cells, while `ChatWidget` owns the current
/// in-flight active cell (often a coalesced exec/tool group). During draws we append that
/// in-flight cell as a cached, render-only live tail so `Ctrl+T` does not appear to "lose" tool
/// calls until a later flush boundary.
///
/// This logic lives here (instead of inside the overlay widget) because `ChatWidget` is the
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let TuiEvent::Draw = &event
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();
let chat_widget = &self.chat_widget;
tui.draw(u16::MAX, |frame| {
let width = frame.area().width.max(1);
t.sync_live_tail(width, active_key, |w| {
chat_widget.active_cell_transcript_lines(w)
});
t.render(frame.area(), frame.buffer);
})?;
let close_overlay = t.is_done();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
{
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
}
if close_overlay {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
return Ok(());
}
if let Some(overlay) = &mut self.overlay {
overlay.handle_event(tui, event)?;
if overlay.is_done() {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
}
Ok(())
}
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
let nth_user_message = self.backtrack.nth_user_message;
let selection = self.backtrack_selection(nth_user_message);
self.close_transcript_overlay(tui);
if let Some(selection) = selection {
self.apply_backtrack_rollback(selection);
tui.frame_requester().schedule_frame();
}
}
/// Handle Esc in overlay backtrack preview: step selection if armed, else forward.
fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if self.backtrack.base_id.is_some() {
self.step_backtrack_and_highlight(tui);
} else {
self.overlay_forward_event(tui, event)?;
}
Ok(())
}
/// Handle Right in overlay backtrack preview: step selection forward if armed, else forward.
fn overlay_step_backtrack_forward(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<()> {
if self.backtrack.base_id.is_some() {
self.step_forward_backtrack_and_highlight(tui);
} else {
self.overlay_forward_event(tui, event)?;
}
Ok(())
}
/// Confirm a primed backtrack from the main view (no overlay visible).
/// Computes the prefill from the selected user message for rollback.
pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {
let selection = self.backtrack_selection(self.backtrack.nth_user_message);
self.reset_backtrack_state();
selection
}
/// Clear all backtrack-related state and composer hints.
pub(crate) fn reset_backtrack_state(&mut self) {
self.backtrack.primed = false;
self.backtrack.base_id = None;
self.backtrack.nth_user_message = usize::MAX;
// In case a hint is somehow still visible (e.g., race with overlay open/close).
self.chat_widget.clear_esc_backtrack_hint();
}
pub(crate) fn apply_backtrack_selection(
&mut self,
tui: &mut tui::Tui,
selection: BacktrackSelection,
) {
self.apply_backtrack_rollback(selection);
tui.frame_requester().schedule_frame();
}
pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) {
match event {
EventMsg::ThreadRolledBack(rollback) => {
// `pending_rollback` is set only after this UI sends `Op::ThreadRollback`
// from the backtrack flow. In that case, finish immediately using the
// stored selection (nth user message) so local trim matches the exact
// backtrack target.
//
// When it is `None`, rollback came from replay or another source. We
// queue an AppEvent so rollback trim runs in FIFO order with
// `InsertHistoryCell` events, avoiding races with in-flight transcript
// inserts.
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
});
}
}
EventMsg::Error(ErrorEvent {
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
..
}) => {
// Core rejected the rollback; clear the guard so the user can retry.
self.backtrack.pending_rollback = None;
}
_ => {}
}
}
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
/// in-flight backtrack request (`pending_rollback` is `None`).
///
/// Returns `true` when local transcript state changed.
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) {
return false;
}
self.sync_overlay_after_transcript_trim();
self.backtrack_render_pending = true;
true
}
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
///
/// We ignore events that do not correspond to the currently active thread to avoid applying
/// stale updates after a session switch.
fn finish_pending_backtrack(&mut self) {
let Some(pending) = self.backtrack.pending_rollback.take() else {
return;
};
if pending.thread_id != self.chat_widget.thread_id() {
// Ignore rollbacks targeting a prior thread.
return;
}
if trim_transcript_cells_to_nth_user(
&mut self.transcript_cells,
pending.selection.nth_user_message,
) {
self.sync_overlay_after_transcript_trim();
self.backtrack_render_pending = true;
}
}
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
let base_id = self.backtrack.base_id?;
if self.chat_widget.thread_id() != Some(base_id) {
return None;
}
let (prefill, text_elements, local_image_paths, remote_image_urls) =
nth_user_position(&self.transcript_cells, nth_user_message)
.and_then(|idx| self.transcript_cells.get(idx))
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())
.map(|cell| {
(
cell.message.clone(),
cell.text_elements.clone(),
cell.local_image_paths.clone(),
cell.remote_image_urls.clone(),
)
})
.unwrap_or_else(|| (String::new(), Vec::new(), Vec::new(), Vec::new()));
Some(BacktrackSelection {
nth_user_message,
prefill,
text_elements,
local_image_paths,
remote_image_urls,
})
}
/// Keep transcript-related UI state aligned after `transcript_cells` was trimmed.
///
/// This does three things:
/// 1. If transcript overlay is open, replace its committed cells so removed turns disappear.
/// 2. If backtrack preview is active, clamp/recompute the highlighted user selection.
/// 3. Drop deferred transcript lines buffered while overlay was open to avoid flushing lines
/// for cells that were just removed by the trim.
fn sync_overlay_after_transcript_trim(&mut self) {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.replace_cells(self.transcript_cells.clone());
}
if self.backtrack.overlay_preview_active {
let total_users = user_count(&self.transcript_cells);
let next_selection = if total_users == 0 {
usize::MAX
} else {
self.backtrack
.nth_user_message
.min(total_users.saturating_sub(1))
};
self.apply_backtrack_selection_internal(next_selection);
}
// While overlay is open, we buffer rendered history lines and flush them on close.
// If rollback trimmed cells meanwhile, those buffered lines can reference removed turns.
self.deferred_history_lines.clear();
}
}
fn trim_transcript_cells_to_nth_user(
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
nth_user_message: usize,
) -> bool {
if nth_user_message == usize::MAX {
return false;
}
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) {
let original_len = transcript_cells.len();
transcript_cells.truncate(cut_idx);
return transcript_cells.len() != original_len;
}
false
}
pub(crate) fn trim_transcript_cells_drop_last_n_user_turns(
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>,
num_turns: u32,
) -> bool {
if num_turns == 0 {
return false;
}
let user_positions: Vec<usize> = user_positions_iter(transcript_cells).collect();
let Some(&first_user_idx) = user_positions.first() else {
return false;
};
let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX);
let cut_idx = if turns_from_end >= user_positions.len() {
first_user_idx
} else {
user_positions[user_positions.len() - turns_from_end]
};
let original_len = transcript_cells.len();
transcript_cells.truncate(cut_idx);
transcript_cells.len() != original_len
}
pub(crate) fn user_count(cells: &[Arc<dyn crate::history_cell::HistoryCell>]) -> usize {
user_positions_iter(cells).count()
}
fn nth_user_position(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
nth: usize,
) -> Option<usize> {
user_positions_iter(cells)
.enumerate()
.find_map(|(i, idx)| (i == nth).then_some(idx))
}
fn user_positions_iter(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
) -> impl Iterator<Item = usize> + '_ {
let session_start_type = TypeId::of::<SessionInfoCell>();
let user_type = TypeId::of::<UserHistoryCell>();
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
let start = cells
.iter()
.rposition(|cell| type_of(cell) == session_start_type)
.map_or(0, |idx| idx + 1);
cells
.iter()
.enumerate()
.skip(start)
.filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use ratatui::prelude::Line;
use std::sync::Arc;
#[test]
fn trim_transcript_for_first_user_drops_user_and_newer_cells() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "first user".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
assert!(cells.is_empty());
}
#[test]
fn trim_transcript_preserves_cells_before_selected_user() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 0);
assert_eq!(cells.len(), 1);
let agent = cells[0]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("agent cell");
let agent_lines = agent.display_lines(u16::MAX);
assert_eq!(agent_lines.len(), 1);
let intro_text: String = agent_lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(intro_text, "• intro");
}
#[test]
fn trim_transcript_for_later_user_keeps_prior_history() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
as Arc<dyn HistoryCell>,
];
trim_transcript_cells_to_nth_user(&mut cells, 1);
assert_eq!(cells.len(), 3);
let agent_intro = cells[0]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("intro agent");
let intro_lines = agent_intro.display_lines(u16::MAX);
let intro_text: String = intro_lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(intro_text, "• intro");
let user_first = cells[1]
.as_any()
.downcast_ref::<UserHistoryCell>()
.expect("first user");
assert_eq!(user_first.message, "first");
let agent_between = cells[2]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("between agent");
let between_lines = agent_between.display_lines(u16::MAX);
let between_text: String = between_lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(between_text, " between");
}
#[test]
fn trim_drop_last_n_user_turns_applies_rollback_semantics() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after first")],
false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after second")],
false,
)) as Arc<dyn HistoryCell>,
];
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, 1);
assert!(changed);
assert_eq!(cells.len(), 2);
let first_user = cells[0]
.as_any()
.downcast_ref::<UserHistoryCell>()
.expect("first user");
assert_eq!(first_user.message, "first");
}
#[test]
fn trim_drop_last_n_user_turns_allows_overflow() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true))
as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
];
let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, u32::MAX);
assert!(changed);
assert_eq!(cells.len(), 1);
let intro = cells[0]
.as_any()
.downcast_ref::<AgentMessageCell>()
.expect("intro agent");
let intro_lines = intro.display_lines(u16::MAX);
let intro_text: String = intro_lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert_eq!(intro_text, "• intro");
}
}
+412
View File
@@ -0,0 +1,412 @@
use std::path::PathBuf;
use codex_core::config::types::ApprovalsReviewer;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct AppCommand(Op);
#[allow(clippy::large_enum_variant)]
#[allow(dead_code)]
pub(crate) enum AppCommandView<'a> {
Interrupt,
CleanBackgroundTerminals,
RealtimeConversationStart(&'a ConversationStartParams),
RealtimeConversationAudio(&'a ConversationAudioParams),
RealtimeConversationText(&'a ConversationTextParams),
RealtimeConversationClose,
UserTurn {
items: &'a [UserInput],
cwd: &'a PathBuf,
approval_policy: AskForApproval,
sandbox_policy: &'a SandboxPolicy,
model: &'a str,
effort: Option<ReasoningEffortConfig>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
final_output_json_schema: &'a Option<Value>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
},
OverrideTurnContext {
cwd: &'a Option<PathBuf>,
approval_policy: &'a Option<AskForApproval>,
approvals_reviewer: &'a Option<ApprovalsReviewer>,
sandbox_policy: &'a Option<SandboxPolicy>,
windows_sandbox_level: &'a Option<WindowsSandboxLevel>,
model: &'a Option<String>,
effort: &'a Option<Option<ReasoningEffortConfig>>,
summary: &'a Option<ReasoningSummaryConfig>,
service_tier: &'a Option<Option<ServiceTier>>,
collaboration_mode: &'a Option<CollaborationMode>,
personality: &'a Option<Personality>,
},
ExecApproval {
id: &'a str,
turn_id: &'a Option<String>,
decision: &'a ReviewDecision,
},
PatchApproval {
id: &'a str,
decision: &'a ReviewDecision,
},
ResolveElicitation {
server_name: &'a str,
request_id: &'a McpRequestId,
decision: &'a ElicitationAction,
content: &'a Option<Value>,
meta: &'a Option<Value>,
},
UserInputAnswer {
id: &'a str,
response: &'a RequestUserInputResponse,
},
RequestPermissionsResponse {
id: &'a str,
response: &'a RequestPermissionsResponse,
},
ReloadUserConfig,
ListSkills {
cwds: &'a [PathBuf],
force_reload: bool,
},
Compact,
SetThreadName {
name: &'a str,
},
Shutdown,
ThreadRollback {
num_turns: u32,
},
Review {
review_request: &'a ReviewRequest,
},
Other(&'a Op),
}
impl AppCommand {
pub(crate) fn interrupt() -> Self {
Self(Op::Interrupt)
}
pub(crate) fn clean_background_terminals() -> Self {
Self(Op::CleanBackgroundTerminals)
}
pub(crate) fn realtime_conversation_start(params: ConversationStartParams) -> Self {
Self(Op::RealtimeConversationStart(params))
}
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
pub(crate) fn realtime_conversation_audio(params: ConversationAudioParams) -> Self {
Self(Op::RealtimeConversationAudio(params))
}
#[allow(dead_code)]
pub(crate) fn realtime_conversation_text(params: ConversationTextParams) -> Self {
Self(Op::RealtimeConversationText(params))
}
pub(crate) fn realtime_conversation_close() -> Self {
Self(Op::RealtimeConversationClose)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn user_turn(
items: Vec<UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
model: String,
effort: Option<ReasoningEffortConfig>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
final_output_json_schema: Option<Value>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
Self(Op::UserTurn {
items,
cwd,
approval_policy,
sandbox_policy,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn override_turn_context(
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<ApprovalsReviewer>,
sandbox_policy: Option<SandboxPolicy>,
windows_sandbox_level: Option<WindowsSandboxLevel>,
model: Option<String>,
effort: Option<Option<ReasoningEffortConfig>>,
summary: Option<ReasoningSummaryConfig>,
service_tier: Option<Option<ServiceTier>>,
collaboration_mode: Option<CollaborationMode>,
personality: Option<Personality>,
) -> Self {
Self(Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
})
}
pub(crate) fn exec_approval(
id: String,
turn_id: Option<String>,
decision: ReviewDecision,
) -> Self {
Self(Op::ExecApproval {
id,
turn_id,
decision,
})
}
pub(crate) fn patch_approval(id: String, decision: ReviewDecision) -> Self {
Self(Op::PatchApproval { id, decision })
}
pub(crate) fn resolve_elicitation(
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<Value>,
meta: Option<Value>,
) -> Self {
Self(Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
})
}
pub(crate) fn user_input_answer(id: String, response: RequestUserInputResponse) -> Self {
Self(Op::UserInputAnswer { id, response })
}
pub(crate) fn request_permissions_response(
id: String,
response: RequestPermissionsResponse,
) -> Self {
Self(Op::RequestPermissionsResponse { id, response })
}
pub(crate) fn reload_user_config() -> Self {
Self(Op::ReloadUserConfig)
}
pub(crate) fn list_skills(cwds: Vec<PathBuf>, force_reload: bool) -> Self {
Self(Op::ListSkills { cwds, force_reload })
}
pub(crate) fn compact() -> Self {
Self(Op::Compact)
}
pub(crate) fn set_thread_name(name: String) -> Self {
Self(Op::SetThreadName { name })
}
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
Self(Op::ThreadRollback { num_turns })
}
pub(crate) fn review(review_request: ReviewRequest) -> Self {
Self(Op::Review { review_request })
}
#[allow(dead_code)]
pub(crate) fn kind(&self) -> &'static str {
self.0.kind()
}
#[allow(dead_code)]
pub(crate) fn as_core(&self) -> &Op {
&self.0
}
pub(crate) fn into_core(self) -> Op {
self.0
}
pub(crate) fn is_review(&self) -> bool {
matches!(self.view(), AppCommandView::Review { .. })
}
pub(crate) fn view(&self) -> AppCommandView<'_> {
match &self.0 {
Op::Interrupt => AppCommandView::Interrupt,
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
Op::RealtimeConversationStart(params) => {
AppCommandView::RealtimeConversationStart(params)
}
Op::RealtimeConversationAudio(params) => {
AppCommandView::RealtimeConversationAudio(params)
}
Op::RealtimeConversationText(params) => {
AppCommandView::RealtimeConversationText(params)
}
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
Op::UserTurn {
items,
cwd,
approval_policy,
sandbox_policy,
model,
effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
} => AppCommandView::UserTurn {
items,
cwd,
approval_policy: *approval_policy,
sandbox_policy,
model,
effort: *effort,
summary,
service_tier,
final_output_json_schema,
collaboration_mode,
personality,
},
Op::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => AppCommandView::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
},
Op::ExecApproval {
id,
turn_id,
decision,
} => AppCommandView::ExecApproval {
id,
turn_id,
decision,
},
Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision },
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
} => AppCommandView::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
Op::UserInputAnswer { id, response } => {
AppCommandView::UserInputAnswer { id, response }
}
Op::RequestPermissionsResponse { id, response } => {
AppCommandView::RequestPermissionsResponse { id, response }
}
Op::ReloadUserConfig => AppCommandView::ReloadUserConfig,
Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills {
cwds,
force_reload: *force_reload,
},
Op::Compact => AppCommandView::Compact,
Op::SetThreadName { name } => AppCommandView::SetThreadName { name },
Op::Shutdown => AppCommandView::Shutdown,
Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback {
num_turns: *num_turns,
},
Op::Review { review_request } => AppCommandView::Review { review_request },
op => AppCommandView::Other(op),
}
}
}
impl From<Op> for AppCommand {
fn from(value: Op) -> Self {
Self(value)
}
}
impl From<&Op> for AppCommand {
fn from(value: &Op) -> Self {
Self(value.clone())
}
}
impl From<&AppCommand> for AppCommand {
fn from(value: &AppCommand) -> Self {
value.clone()
}
}
impl From<AppCommand> for Op {
fn from(value: AppCommand) -> Self {
value.0
}
}
+488
View File
@@ -0,0 +1,488 @@
//! Application-level events used to coordinate UI actions.
//!
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
//! Widgets emit events to request actions that must be handled at the app layer (like opening
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
//! `App` internals.
//!
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::history_cell::HistoryCell;
use codex_core::config::types::ApprovalsReviewer;
use codex_core::features::Feature;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeAudioDeviceKind {
Microphone,
Speaker,
}
impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
}
}
pub(crate) fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Speaker => "speaker",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
Elevated,
Legacy,
}
#[derive(Debug, Clone)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) struct ConnectorsSnapshot {
pub(crate) connectors: Vec<AppInfo>,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
CodexEvent(Event),
/// Open the agent picker for switching active threads.
OpenAgentPicker,
/// Switch the active thread to the selected agent.
SelectAgentThread(ThreadId),
/// Submit an op to the specified thread, regardless of current focus.
SubmitThreadOp {
thread_id: ThreadId,
op: Op,
},
/// Forward an event from a non-primary thread into the app-level thread router.
#[allow(dead_code)]
ThreadEvent {
thread_id: ThreadId,
event: Event,
},
/// Start a new session.
NewSession,
/// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the
/// previous chat resumable.
ClearUi,
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Fork the current session into a new thread.
ForkCurrentSession,
/// Request to exit the application.
///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
/// background tasks, rollout flush, or child process cleanup).
Exit(ExitMode),
/// Request to exit the application due to a fatal error.
#[allow(dead_code)]
FatalExitRequest(String),
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
/// bubbling channels through layers of widgets.
CodexOp(Op),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
/// is at most one in-flight search.
StartFileSearch(String),
/// Result of a completed asynchronous file search. The `query` echoes the
/// original search term so the UI can decide whether the results are
/// still relevant.
FileSearchResult {
query: String,
matches: Vec<FileMatch>,
},
/// Result of refreshing rate limits
#[allow(dead_code)]
RateLimitSnapshotFetched(RateLimitSnapshot),
/// Result of prefetching connectors.
ConnectorsLoaded {
result: Result<ConnectorsSnapshot, String>,
is_final: bool,
},
/// Result of computing a `/diff` command.
DiffResult(String),
/// Open the app link view in the bottom pane.
OpenAppLink {
app_id: String,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
is_enabled: bool,
},
/// Open the provided URL in the user's browser.
OpenUrlInBrowser {
url: String,
},
/// Refresh app connector state and mention bindings.
RefreshConnectors {
force_refetch: bool,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
///
/// This is emitted when rollback was not initiated by the current
/// backtrack flow so trimming occurs in AppEvent queue order relative to
/// inserted history cells.
ApplyThreadRollback {
num_turns: u32,
},
StartCommitAnimation,
StopCommitAnimation,
CommitTick,
/// Update the current reasoning effort in the running app and widget.
UpdateReasoningEffort(Option<ReasoningEffort>),
/// Update the current model slug in the running app and widget.
UpdateModel(String),
/// Update the active collaboration mask in the running app and widget.
UpdateCollaborationMode(CollaborationModeMask),
/// Update the current personality in the running app and widget.
UpdatePersonality(Personality),
/// Persist the selected model and reasoning effort to the appropriate config.
PersistModelSelection {
model: String,
effort: Option<ReasoningEffort>,
},
/// Persist the selected personality to the appropriate config.
PersistPersonalitySelection {
personality: Personality,
},
/// Persist the selected service tier to the appropriate config.
PersistServiceTierSelection {
service_tier: Option<ServiceTier>,
},
/// Open the device picker for a realtime microphone or speaker.
OpenRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
},
/// Persist the selected realtime microphone or speaker to top-level config.
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
name: Option<String>,
},
/// Restart the selected realtime microphone or speaker locally.
RestartRealtimeAudioDevice {
kind: RealtimeAudioDeviceKind,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,
},
/// Open the Plan-mode reasoning scope prompt for the selected model/effort.
OpenPlanReasoningScopePrompt {
model: String,
effort: Option<ReasoningEffort>,
},
/// Open the full model picker (non-auto models).
OpenAllModelsPopup {
models: Vec<ModelPreset>,
},
/// Open the confirmation prompt before enabling full access mode.
OpenFullAccessConfirmation {
preset: ApprovalPreset,
return_to_permissions: bool,
},
/// Open the Windows world-writable directories warning.
/// If `preset` is `Some`, the confirmation will apply the provided
/// approval/sandbox configuration on Continue; if `None`, it performs no
/// policy change and only acknowledges/dismisses the warning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWorldWritableWarningConfirmation {
preset: Option<ApprovalPreset>,
/// Up to 3 sample world-writable directories to display in the warning.
sample_paths: Vec<String>,
/// If there are more than `sample_paths`, this carries the remaining count.
extra_count: usize,
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
failed_scan: bool,
},
/// Prompt to enable the Windows sandbox feature before using Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxEnablePrompt {
preset: ApprovalPreset,
},
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxFallbackPrompt {
preset: ApprovalPreset,
},
/// Begin the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxElevatedSetup {
preset: ApprovalPreset,
},
/// Begin the non-elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxLegacySetup {
preset: ApprovalPreset,
},
/// Begin a non-elevated grant of read access for an additional directory.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxGrantReadRoot {
path: String,
},
/// Result of attempting to grant read access for an additional directory.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
WindowsSandboxGrantReadRootCompleted {
path: PathBuf,
error: Option<String>,
},
/// Enable the Windows sandbox feature and switch to Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
EnableWindowsSandboxForAgentMode {
preset: ApprovalPreset,
mode: WindowsSandboxEnableMode,
},
/// Update the Windows sandbox feature mode without changing approval presets.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),
/// Update the current approvals reviewer in the running app and widget.
UpdateApprovalsReviewer(ApprovalsReviewer),
/// Update feature flags and persist them to the top-level config.
UpdateFeatureFlags {
updates: Vec<(Feature, bool)>,
},
/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),
/// Update whether the world-writable directories warning has been acknowledged.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
UpdateWorldWritableWarningAcknowledged(bool),
/// Update whether the rate limit switch prompt has been acknowledged for the session.
UpdateRateLimitSwitchPromptHidden(bool),
/// Update the Plan-mode-specific reasoning effort in memory.
UpdatePlanModeReasoningEffort(Option<ReasoningEffort>),
/// Persist the acknowledgement flag for the full access warning prompt.
PersistFullAccessWarningAcknowledged,
/// Persist the acknowledgement flag for the world-writable directories warning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
PersistWorldWritableWarningAcknowledged,
/// Persist the acknowledgement flag for the rate limit switch prompt.
PersistRateLimitSwitchPromptHidden,
/// Persist the Plan-mode-specific reasoning effort.
PersistPlanModeReasoningEffort(Option<ReasoningEffort>),
/// Persist the acknowledgement flag for the model migration prompt.
PersistModelMigrationPromptAcknowledged {
from_model: String,
to_model: String,
},
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
SkipNextWorldWritableScan,
/// Re-open the approval presets popup.
OpenApprovalsPopup,
/// Open the skills list popup.
OpenSkillsList,
/// Open the skills enable/disable picker.
OpenManageSkillsPopup,
/// Enable or disable a skill by path.
SetSkillEnabled {
path: PathBuf,
enabled: bool,
},
/// Enable or disable an app by connector ID.
SetAppEnabled {
id: String,
enabled: bool,
},
/// Notify that the manage skills popup was closed.
ManageSkillsClosed,
/// Re-open the permissions presets popup.
OpenPermissionsPopup,
/// Live update for the in-progress voice recording placeholder. Carries
/// the placeholder `id` and the text to display (e.g., an ASCII meter).
#[cfg(not(target_os = "linux"))]
UpdateRecordingMeter {
id: String,
text: String,
},
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
TranscriptionComplete {
id: String,
text: String,
},
/// Voice transcription failed; remove the placeholder identified by `id`.
#[cfg(not(target_os = "linux"))]
TranscriptionFailed {
id: String,
#[allow(dead_code)]
error: String,
},
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
/// Open the commit picker option from the review popup.
OpenReviewCommitPicker(PathBuf),
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Submit a user message with an explicit collaboration mask.
SubmitUserMessageWithMode {
text: String,
collaboration_mode: CollaborationModeMask,
},
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
/// Open the feedback note entry overlay after the user selects a category.
OpenFeedbackNote {
category: FeedbackCategory,
include_logs: bool,
},
/// Open the upload consent popup for feedback after selecting a category.
OpenFeedbackConsent {
category: FeedbackCategory,
},
/// Launch the external editor after a normal draw has completed.
LaunchExternalEditor,
/// Async update of the current git branch for status line rendering.
StatusLineBranchUpdated {
cwd: PathBuf,
branch: Option<String>,
},
/// Apply a user-confirmed status-line item ordering/selection.
StatusLineSetup {
items: Vec<StatusLineItem>,
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected {
name: String,
},
}
/// The exit strategy requested by the UI layer.
///
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
/// already completed (or is being bypassed) and the UI loop should terminate right away.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExitMode {
/// Shutdown core and exit after completion.
ShutdownFirst,
/// Exit the UI loop immediately without waiting for shutdown.
///
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
/// cleanup that normally runs before `ShutdownComplete` can be missed.
Immediate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
GoodResult,
Bug,
SafetyCheck,
Other,
}
@@ -0,0 +1,123 @@
use std::path::PathBuf;
use crate::app_command::AppCommand;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::mpsc::UnboundedSender;
use crate::app_event::AppEvent;
use crate::session_log;
#[derive(Clone, Debug)]
pub(crate) struct AppEventSender {
pub app_event_tx: UnboundedSender<AppEvent>,
}
impl AppEventSender {
pub(crate) fn new(app_event_tx: UnboundedSender<AppEvent>) -> Self {
Self { app_event_tx }
}
/// Send an event to the app event channel. If it fails, we swallow the
/// error and log it.
pub(crate) fn send(&self, event: AppEvent) {
// Record inbound events for high-fidelity session replay.
// Avoid double-logging Ops; those are logged at the point of submission.
if !matches!(event, AppEvent::CodexOp(_)) {
session_log::log_inbound_app_event(&event);
}
if let Err(e) = self.app_event_tx.send(event) {
tracing::error!("failed to send event: {e}");
}
}
pub(crate) fn interrupt(&self) {
self.send(AppEvent::CodexOp(AppCommand::interrupt().into_core()));
}
pub(crate) fn compact(&self) {
self.send(AppEvent::CodexOp(AppCommand::compact().into_core()));
}
pub(crate) fn set_thread_name(&self, name: String) {
self.send(AppEvent::CodexOp(
AppCommand::set_thread_name(name).into_core(),
));
}
pub(crate) fn review(&self, review_request: ReviewRequest) {
self.send(AppEvent::CodexOp(
AppCommand::review(review_request).into_core(),
));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(
AppCommand::list_skills(cwds, force_reload).into_core(),
));
}
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
pub(crate) fn realtime_conversation_audio(&self, params: ConversationAudioParams) {
self.send(AppEvent::CodexOp(
AppCommand::realtime_conversation_audio(params).into_core(),
));
}
pub(crate) fn user_input_answer(&self, id: String, response: RequestUserInputResponse) {
self.send(AppEvent::CodexOp(
AppCommand::user_input_answer(id, response).into_core(),
));
}
pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::exec_approval(id, None, decision).into_core(),
});
}
pub(crate) fn request_permissions_response(
&self,
thread_id: ThreadId,
id: String,
response: RequestPermissionsResponse,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::request_permissions_response(id, response).into_core(),
});
}
pub(crate) fn patch_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::patch_approval(id, decision).into_core(),
});
}
pub(crate) fn resolve_elicitation(
&self,
thread_id: ThreadId,
server_name: String,
request_id: McpRequestId,
decision: ElicitationAction,
content: Option<serde_json::Value>,
meta: Option<serde_json::Value>,
) {
self.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::resolve_elicitation(server_name, request_id, decision, content, meta)
.into_core(),
});
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,111 @@
use std::convert::TryFrom;
use std::time::Duration;
use std::time::Instant;
use rand::Rng as _;
use crate::frames::ALL_VARIANTS;
use crate::frames::FRAME_TICK_DEFAULT;
use crate::tui::FrameRequester;
/// Drives ASCII art animations shared across popups and onboarding widgets.
pub(crate) struct AsciiAnimation {
request_frame: FrameRequester,
variants: &'static [&'static [&'static str]],
variant_idx: usize,
frame_tick: Duration,
start: Instant,
}
impl AsciiAnimation {
pub(crate) fn new(request_frame: FrameRequester) -> Self {
Self::with_variants(request_frame, ALL_VARIANTS, 0)
}
pub(crate) fn with_variants(
request_frame: FrameRequester,
variants: &'static [&'static [&'static str]],
variant_idx: usize,
) -> Self {
assert!(
!variants.is_empty(),
"AsciiAnimation requires at least one animation variant",
);
let clamped_idx = variant_idx.min(variants.len() - 1);
Self {
request_frame,
variants,
variant_idx: clamped_idx,
frame_tick: FRAME_TICK_DEFAULT,
start: Instant::now(),
}
}
pub(crate) fn schedule_next_frame(&self) {
let tick_ms = self.frame_tick.as_millis();
if tick_ms == 0 {
self.request_frame.schedule_frame();
return;
}
let elapsed_ms = self.start.elapsed().as_millis();
let rem_ms = elapsed_ms % tick_ms;
let delay_ms = if rem_ms == 0 {
tick_ms
} else {
tick_ms - rem_ms
};
if let Ok(delay_ms_u64) = u64::try_from(delay_ms) {
self.request_frame
.schedule_frame_in(Duration::from_millis(delay_ms_u64));
} else {
self.request_frame.schedule_frame();
}
}
pub(crate) fn current_frame(&self) -> &'static str {
let frames = self.frames();
if frames.is_empty() {
return "";
}
let tick_ms = self.frame_tick.as_millis();
if tick_ms == 0 {
return frames[0];
}
let elapsed_ms = self.start.elapsed().as_millis();
let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize;
frames[idx]
}
pub(crate) fn pick_random_variant(&mut self) -> bool {
if self.variants.len() <= 1 {
return false;
}
let mut rng = rand::rng();
let mut next = self.variant_idx;
while next == self.variant_idx {
next = rng.random_range(0..self.variants.len());
}
self.variant_idx = next;
self.request_frame.schedule_frame();
true
}
#[allow(dead_code)]
pub(crate) fn request_frame(&self) {
self.request_frame.schedule_frame();
}
fn frames(&self) -> &'static [&'static str] {
self.variants[self.variant_idx]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_tick_must_be_nonzero() {
assert!(FRAME_TICK_DEFAULT.as_millis() > 0);
}
}
+176
View File
@@ -0,0 +1,176 @@
use codex_core::config::Config;
use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use tracing::warn;
use crate::app_event::RealtimeAudioDeviceKind;
const PREFERRED_INPUT_SAMPLE_RATE: u32 = 24_000;
const PREFERRED_INPUT_CHANNELS: u16 = 1;
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
let host = cpal::default_host();
let mut device_names = Vec::new();
for device in devices(&host, kind)? {
let Ok(name) = device.name() else {
continue;
};
if !device_names.contains(&name) {
device_names.push(name);
}
}
Ok(device_names)
}
pub(crate) fn select_configured_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
}
pub(crate) fn select_configured_output_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
}
pub(crate) fn preferred_input_config(
device: &cpal::Device,
) -> Result<cpal::SupportedStreamConfig, String> {
let supported_configs = device
.supported_input_configs()
.map_err(|err| format!("failed to enumerate input audio configs: {err}"))?;
supported_configs
.filter_map(|range| {
let sample_format_rank = match range.sample_format() {
cpal::SampleFormat::I16 => 0u8,
cpal::SampleFormat::U16 => 1u8,
cpal::SampleFormat::F32 => 2u8,
_ => return None,
};
let sample_rate = preferred_input_sample_rate(&range);
let sample_rate_penalty = sample_rate.0.abs_diff(PREFERRED_INPUT_SAMPLE_RATE);
let channel_penalty = range.channels().abs_diff(PREFERRED_INPUT_CHANNELS);
Some((
(sample_rate_penalty, channel_penalty, sample_format_rank),
range.with_sample_rate(sample_rate),
))
})
.min_by_key(|(score, _)| *score)
.map(|(_, config)| config)
.or_else(|| device.default_input_config().ok())
.ok_or_else(|| "failed to get default input config".to_string())
}
fn select_device_and_config(
kind: RealtimeAudioDeviceKind,
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let configured_name = configured_name(kind, config);
let selected = configured_name
.and_then(|name| find_device_by_name(&host, kind, name))
.or_else(|| {
let default_device = default_device(&host, kind);
if let Some(name) = configured_name && default_device.is_some() {
warn!(
"configured {} audio device `{name}` was unavailable; falling back to system default",
kind.noun()
);
}
default_device
})
.ok_or_else(|| missing_device_error(kind, configured_name))?;
let stream_config = match kind {
RealtimeAudioDeviceKind::Microphone => preferred_input_config(&selected)?,
RealtimeAudioDeviceKind::Speaker => default_config(&selected, kind)?,
};
Ok((selected, stream_config))
}
fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
match kind {
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
}
}
fn find_device_by_name(
host: &cpal::Host,
kind: RealtimeAudioDeviceKind,
name: &str,
) -> Option<cpal::Device> {
let devices = devices(host, kind).ok()?;
devices
.into_iter()
.find(|device| device.name().ok().as_deref() == Some(name))
}
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
match kind {
RealtimeAudioDeviceKind::Microphone => host
.input_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
RealtimeAudioDeviceKind::Speaker => host
.output_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
}
}
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
match kind {
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
}
}
fn default_config(
device: &cpal::Device,
kind: RealtimeAudioDeviceKind,
) -> Result<cpal::SupportedStreamConfig, String> {
match kind {
RealtimeAudioDeviceKind::Microphone => device
.default_input_config()
.map_err(|err| format!("failed to get default input config: {err}")),
RealtimeAudioDeviceKind::Speaker => device
.default_output_config()
.map_err(|err| format!("failed to get default output config: {err}")),
}
}
fn preferred_input_sample_rate(range: &cpal::SupportedStreamConfigRange) -> cpal::SampleRate {
let min = range.min_sample_rate().0;
let max = range.max_sample_rate().0;
if (min..=max).contains(&PREFERRED_INPUT_SAMPLE_RATE) {
cpal::SampleRate(PREFERRED_INPUT_SAMPLE_RATE)
} else if PREFERRED_INPUT_SAMPLE_RATE < min {
cpal::SampleRate(min)
} else {
cpal::SampleRate(max)
}
}
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
format!(
"configured microphone `{name}` was unavailable and no default input audio device was found"
)
}
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
format!(
"configured speaker `{name}` was unavailable and no default output audio device was found"
)
}
(RealtimeAudioDeviceKind::Microphone, None) => {
"no input audio device available".to_string()
}
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
}
}
@@ -0,0 +1,15 @@
use std::io::Read;
use std::io::{self};
fn main() {
let mut input = String::new();
if let Err(err) = io::stdin().read_to_string(&mut input) {
eprintln!("failed to read stdin: {err}");
std::process::exit(1);
}
let parser = pulldown_cmark::Parser::new(&input);
for event in parser {
println!("{event:?}");
}
}
@@ -0,0 +1,14 @@
# TUI bottom pane (state machines)
When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync:
- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a
readable, top-down explanation of the current behavior.
- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter
handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling).
- Keep implementations/docstrings aligned unless a divergence is intentional and documented.
Practical check:
- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the
Enter/newline paths and `disable_paste_burst` semantics).
@@ -0,0 +1,943 @@
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
#[cfg(test)]
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use textwrap::wrap;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AppLinkScreen {
Link,
InstallConfirmation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AppLinkSuggestionType {
Install,
Enable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkElicitationTarget {
pub(crate) thread_id: ThreadId,
pub(crate) server_name: String,
pub(crate) request_id: McpRequestId,
}
pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) is_installed: bool,
pub(crate) is_enabled: bool,
pub(crate) suggest_reason: Option<String>,
pub(crate) suggestion_type: Option<AppLinkSuggestionType>,
pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
}
pub(crate) struct AppLinkView {
app_id: String,
title: String,
description: Option<String>,
instructions: String,
url: String,
is_installed: bool,
is_enabled: bool,
suggest_reason: Option<String>,
suggestion_type: Option<AppLinkSuggestionType>,
elicitation_target: Option<AppLinkElicitationTarget>,
app_event_tx: AppEventSender,
screen: AppLinkScreen,
selected_action: usize,
complete: bool,
}
impl AppLinkView {
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
let AppLinkViewParams {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
suggest_reason,
suggestion_type,
elicitation_target,
} = params;
Self {
app_id,
title,
description,
instructions,
url,
is_installed,
is_enabled,
suggest_reason,
suggestion_type,
elicitation_target,
app_event_tx,
screen: AppLinkScreen::Link,
selected_action: 0,
complete: false,
}
}
fn action_labels(&self) -> Vec<&'static str> {
match self.screen {
AppLinkScreen::Link => {
if self.is_installed {
vec![
"Manage on ChatGPT",
if self.is_enabled {
"Disable app"
} else {
"Enable app"
},
"Back",
]
} else {
vec!["Install on ChatGPT", "Back"]
}
}
AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
}
}
fn move_selection_prev(&mut self) {
self.selected_action = self.selected_action.saturating_sub(1);
}
fn move_selection_next(&mut self) {
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
}
fn is_tool_suggestion(&self) -> bool {
self.elicitation_target.is_some()
}
fn resolve_elicitation(&self, decision: ElicitationAction) {
let Some(target) = self.elicitation_target.as_ref() else {
return;
};
self.app_event_tx.resolve_elicitation(
target.thread_id,
target.server_name.clone(),
target.request_id.clone(),
decision,
None,
None,
);
}
fn decline_tool_suggestion(&mut self) {
self.resolve_elicitation(ElicitationAction::Decline);
self.complete = true;
}
fn open_chatgpt_link(&mut self) {
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
url: self.url.clone(),
});
if !self.is_installed {
self.screen = AppLinkScreen::InstallConfirmation;
self.selected_action = 0;
}
}
fn refresh_connectors_and_close(&mut self) {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Accept);
}
self.complete = true;
}
fn back_to_link_screen(&mut self) {
self.screen = AppLinkScreen::Link;
self.selected_action = 0;
}
fn toggle_enabled(&mut self) {
self.is_enabled = !self.is_enabled;
self.app_event_tx.send(AppEvent::SetAppEnabled {
id: self.app_id.clone(),
enabled: self.is_enabled,
});
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Accept);
self.complete = true;
}
}
fn activate_selected_action(&mut self) {
if self.is_tool_suggestion() {
match self.suggestion_type {
Some(AppLinkSuggestionType::Enable) => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.decline_tool_suggestion(),
},
},
Some(AppLinkSuggestionType::Install) | None => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.decline_tool_suggestion(),
},
},
}
return;
}
match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.complete = true,
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.back_to_link_screen(),
},
}
}
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
match self.screen {
AppLinkScreen::Link => self.link_content_lines(width),
AppLinkScreen::InstallConfirmation => self.install_confirmation_lines(width),
}
}
fn link_content_lines(&self, width: u16) -> Vec<Line<'static>> {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(self.title.clone().bold()));
if let Some(description) = self
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
for line in wrap(description, usable_width) {
lines.push(Line::from(line.into_owned().dim()));
}
}
lines.push(Line::from(""));
if let Some(suggest_reason) = self
.suggest_reason
.as_deref()
.map(str::trim)
.filter(|suggest_reason| !suggest_reason.is_empty())
{
for line in wrap(suggest_reason, usable_width) {
lines.push(Line::from(line.into_owned().italic()));
}
lines.push(Line::from(""));
}
if self.is_installed {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
}
let instructions = self.instructions.trim();
if !instructions.is_empty() {
for line in wrap(instructions, usable_width) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Newly installed apps can take a few minutes to appear in /apps.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
if !self.is_installed {
for line in wrap(
"After installed, use $ to insert this app into the prompt.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
}
lines
}
fn install_confirmation_lines(&self, width: u16) -> Vec<Line<'static>> {
let usable_width = width.max(1) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("Finish App Setup".bold()));
lines.push(Line::from(""));
for line in wrap(
"Complete app setup on ChatGPT in the browser window that just opened.",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
for line in wrap(
"Sign in there if needed, then return here and select \"I already Installed it\".",
usable_width,
) {
lines.push(Line::from(line.into_owned()));
}
lines.push(Line::from(""));
lines.push(Line::from(vec!["Setup URL:".dim()]));
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
lines.extend(adaptive_wrap_lines(
vec![url_line],
RtOptions::new(usable_width),
));
lines
}
fn action_rows(&self) -> Vec<GenericDisplayRow> {
self.action_labels()
.into_iter()
.enumerate()
.map(|(index, label)| {
let prefix = if self.selected_action == index {
'›'
} else {
' '
};
GenericDisplayRow {
name: format!("{prefix} {}. {label}", index + 1),
..Default::default()
}
})
.collect()
}
fn action_state(&self) -> ScrollState {
let mut state = ScrollState::new();
state.selected_idx = Some(self.selected_action);
state
}
fn action_rows_height(&self, width: u16) -> u16 {
let rows = self.action_rows();
let state = self.action_state();
measure_rows_height(&rows, &state, rows.len().max(1), width.max(1))
}
fn hint_line(&self) -> Line<'static> {
Line::from(vec![
"Use ".into(),
key_hint::plain(KeyCode::Tab).into(),
" / ".into(),
key_hint::plain(KeyCode::Up).into(),
" ".into(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to select, ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}
}
impl BottomPaneView for AppLinkView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Left,
..
}
| KeyEvent {
code: KeyCode::BackTab,
..
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_prev(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Right,
..
}
| KeyEvent {
code: KeyCode::Tab, ..
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('l'),
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_next(),
KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
..
} => {
if let Some(index) = c
.to_digit(10)
.and_then(|digit| digit.checked_sub(1))
.map(|index| index as usize)
&& index < self.action_labels().len()
{
self.selected_action = index;
self.activate_selected_action();
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.activate_selected_action(),
_ => {}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Decline);
}
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
}
impl crate::render::renderable::Renderable for AppLinkView {
fn desired_height(&self, width: u16) -> u16 {
let content_width = width.saturating_sub(4).max(1);
let content_lines = self.content_lines(content_width);
let content_rows = Paragraph::new(content_lines)
.wrap(Wrap { trim: false })
.line_count(content_width)
.max(1) as u16;
let action_rows_height = self.action_rows_height(content_width);
content_rows + action_rows_height + 3
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Block::default()
.style(user_message_style())
.render(area, buf);
let actions_height = self.action_rows_height(area.width.saturating_sub(4));
let [content_area, actions_area, hint_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(actions_height),
Constraint::Length(1),
])
.areas(area);
let inner = content_area.inset(Insets::vh(1, 2));
let content_width = inner.width.max(1);
let lines = self.content_lines(content_width);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(inner, buf);
if actions_area.height > 0 {
let actions_area = Rect {
x: actions_area.x.saturating_add(2),
y: actions_area.y,
width: actions_area.width.saturating_sub(2),
height: actions_area.height,
};
let action_rows = self.action_rows();
let action_state = self.action_state();
render_rows(
actions_area,
buf,
&action_rows,
&action_state,
action_rows.len().max(1),
"No actions",
);
}
if hint_area.height > 0 {
let hint_area = Rect {
x: hint_area.x.saturating_add(2),
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
self.hint_line().dim().render(hint_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use insta::assert_snapshot;
use tokio::sync::mpsc::unbounded_channel;
fn suggestion_target() -> AppLinkElicitationTarget {
AppLinkElicitationTarget {
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
.expect("valid thread id"),
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
}
}
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn installed_app_has_toggle_action() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
assert_eq!(
view.action_labels(),
vec!["Manage on ChatGPT", "Disable app", "Back"]
);
}
#[test]
fn toggle_action_sends_set_app_enabled_and_updates_label() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
assert_eq!(id, "connector_1");
assert!(!enabled);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert_eq!(
view.action_labels(),
vec!["Manage on ChatGPT", "Enable app", "Back"]
);
}
#[test]
fn install_confirmation_does_not_split_long_url_like_token_without_scheme() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let url_like =
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: url_like.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
view.screen = AppLinkScreen::InstallConfirmation;
let rendered: Vec<String> = view
.content_lines(40)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
})
.collect();
assert_eq!(
rendered
.iter()
.filter(|line| line.contains(url_like))
.count(),
1,
"expected full URL-like token in one rendered line, got: {rendered:?}"
);
}
#[test]
fn install_confirmation_render_keeps_url_tail_visible_when_narrow() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42";
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: url.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
view.screen = AppLinkScreen::InstallConfirmation;
let width: u16 = 36;
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let rendered_blob = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered_blob.contains("tail42"),
"expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
);
}
#[test]
fn install_tool_suggestion_resolves_elicitation_after_confirmation() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::OpenUrlInBrowser { url }) => {
assert_eq!(url, "https://example.test/google-calendar".to_string());
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::RefreshConnectors { force_refetch }) => {
assert!(force_refetch);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Accept,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn declined_tool_suggestion_resolves_elicitation_decline() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: None,
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Decline,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn enable_tool_suggestion_resolves_elicitation_after_enable() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Enable this app to use it for the current request.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: true,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Enable),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
assert_eq!(id, "connector_google_calendar");
assert!(enabled);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Accept,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn install_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
assert_snapshot!(
"app_link_view_install_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
);
}
#[test]
fn enable_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Enable this app to use it for the current request.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: true,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Enable),
elicitation_target: Some(suggestion_target()),
},
tx,
);
assert_snapshot!(
"app_link_view_enable_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,90 @@
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::render::renderable::Renderable;
use codex_protocol::request_user_input::RequestUserInputEvent;
use crossterm::event::KeyEvent;
use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView: Renderable {
/// Handle a key event while the view is active. A redraw is always
/// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
/// Return `true` if the view has finished and should be removed.
fn is_complete(&self) -> bool {
false
}
/// Stable identifier for views that need external refreshes while open.
fn view_id(&self) -> Option<&'static str> {
None
}
/// Actual item index for list-based views that want to preserve selection
/// across external refreshes.
fn selected_index(&self) -> Option<usize> {
None
}
/// Handle Ctrl-C while this view is active.
fn on_ctrl_c(&mut self) -> CancellationEvent {
CancellationEvent::NotHandled
}
/// Return true if Esc should be routed through `handle_key_event` instead
/// of the `on_ctrl_c` cancellation path.
fn prefer_esc_to_handle_key_event(&self) -> bool {
false
}
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pasted: String) -> bool {
false
}
/// Flush any pending paste-burst state. Return true if state changed.
///
/// This lets a modal that reuses `ChatComposer` participate in the same
/// time-based paste burst flushing as the primary composer.
fn flush_paste_burst_if_due(&mut self) -> bool {
false
}
/// Whether the view is currently holding paste-burst transient state.
///
/// When `true`, the bottom pane will schedule a short delayed redraw to
/// give the burst time window a chance to flush.
fn is_in_paste_burst(&self) -> bool {
false
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
) -> Option<ApprovalRequest> {
Some(request)
}
/// Try to handle request_user_input; return the original value if not
/// consumed.
fn try_consume_user_input_request(
&mut self,
request: RequestUserInputEvent,
) -> Option<RequestUserInputEvent> {
Some(request)
}
/// Try to handle a supported MCP server elicitation form request; return the original value if
/// not consumed.
fn try_consume_mcp_server_elicitation_request(
&mut self,
request: McpServerElicitationFormRequest,
) -> Option<McpServerElicitationFormRequest> {
Some(request)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,429 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::history_cell;
use crate::mention_codec::decode_history_mentions;
use codex_protocol::user_input::TextElement;
use tracing::warn;
/// A composer history entry that can rehydrate draft state.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HistoryEntry {
/// Raw text stored in history (may include placeholder strings).
pub(crate) text: String,
/// Text element ranges for placeholders inside `text`.
pub(crate) text_elements: Vec<TextElement>,
/// Local image paths captured alongside `text_elements`.
pub(crate) local_image_paths: Vec<PathBuf>,
/// Remote image URLs restored with this draft.
pub(crate) remote_image_urls: Vec<String>,
/// Mention bindings for tool/app/skill references inside `text`.
pub(crate) mention_bindings: Vec<MentionBinding>,
/// Placeholder-to-payload pairs used to restore large paste content.
pub(crate) pending_pastes: Vec<(String, String)>,
}
impl HistoryEntry {
pub(crate) fn new(text: String) -> Self {
let decoded = decode_history_mentions(&text);
Self {
text: decoded.text,
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: decoded
.mentions
.into_iter()
.map(|mention| MentionBinding {
mention: mention.mention,
path: mention.path,
})
.collect(),
pending_pastes: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn with_pending(
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
pending_pastes: Vec<(String, String)>,
) -> Self {
Self {
text,
text_elements,
local_image_paths,
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
pending_pastes,
}
}
#[cfg(test)]
pub(crate) fn with_pending_and_remote(
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
pending_pastes: Vec<(String, String)>,
remote_image_urls: Vec<String>,
) -> Self {
Self {
text,
text_elements,
local_image_paths,
remote_image_urls,
mention_bindings: Vec::new(),
pending_pastes,
}
}
}
/// State machine that manages shell-style history navigation (Up/Down) inside
/// the chat composer. This struct is intentionally decoupled from the
/// rendering widget so the logic remains isolated and easier to test.
pub(crate) struct ChatComposerHistory {
/// Identifier of the history log as reported by `SessionConfiguredEvent`.
history_log_id: Option<u64>,
/// Number of entries already present in the persistent cross-session
/// history file when the session started.
history_entry_count: usize,
/// Messages submitted by the user *during this UI session* (newest at END).
/// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs).
local_history: Vec<HistoryEntry>,
/// Cache of persistent history entries fetched on-demand (text-only).
fetched_history: HashMap<usize, HistoryEntry>,
/// Current cursor within the combined (persistent + local) history. `None`
/// indicates the user is *not* currently browsing history.
history_cursor: Option<isize>,
/// The text that was last inserted into the composer as a result of
/// history navigation. Used to decide if further Up/Down presses should be
/// treated as navigation versus normal cursor movement, together with the
/// "cursor at line boundary" check in [`Self::should_handle_navigation`].
last_history_text: Option<String>,
}
impl ChatComposerHistory {
pub fn new() -> Self {
Self {
history_log_id: None,
history_entry_count: 0,
local_history: Vec::new(),
fetched_history: HashMap::new(),
history_cursor: None,
last_history_text: None,
}
}
/// Update metadata when a new session is configured.
pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) {
self.history_log_id = Some(log_id);
self.history_entry_count = entry_count;
self.fetched_history.clear();
self.local_history.clear();
self.history_cursor = None;
self.last_history_text = None;
}
/// Record a message submitted by the user in the current session so it can
/// be recalled later.
pub fn record_local_submission(&mut self, entry: HistoryEntry) {
if entry.text.is_empty()
&& entry.text_elements.is_empty()
&& entry.local_image_paths.is_empty()
&& entry.remote_image_urls.is_empty()
&& entry.mention_bindings.is_empty()
&& entry.pending_pastes.is_empty()
{
return;
}
self.history_cursor = None;
self.last_history_text = None;
// Avoid inserting a duplicate if identical to the previous entry.
if self.local_history.last().is_some_and(|prev| prev == &entry) {
return;
}
self.local_history.push(entry);
}
/// Reset navigation tracking so the next Up key resumes from the latest entry.
pub fn reset_navigation(&mut self) {
self.history_cursor = None;
self.last_history_text = None;
}
/// Returns whether Up/Down should navigate history for the current textarea state.
///
/// Empty text always enables history traversal. For non-empty text, this requires both:
///
/// - the current text exactly matching the last recalled history entry, and
/// - the cursor being at a line boundary (start or end).
///
/// This boundary gate keeps multiline cursor movement usable while preserving shell-like
/// history recall. If callers moved the cursor into the middle of a recalled entry and still
/// forced navigation, users would lose normal vertical movement within the draft.
pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool {
if self.history_entry_count == 0 && self.local_history.is_empty() {
return false;
}
if text.is_empty() {
return true;
}
// Textarea is not empty – only navigate when text matches the last
// recalled history entry and the cursor is at a line boundary. This
// keeps shell-like Up/Down recall working while still allowing normal
// multiline cursor movement from interior positions.
if cursor != 0 && cursor != text.len() {
return false;
}
matches!(&self.last_history_text, Some(prev) if prev == text)
}
/// Handle <Up>. Returns true when the key was consumed and the caller
/// should request a redraw.
pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
}
let next_idx = match self.history_cursor {
None => (total_entries as isize) - 1,
Some(0) => return None, // already at oldest
Some(idx) => idx - 1,
};
self.history_cursor = Some(next_idx);
self.populate_history_at_index(next_idx as usize, app_event_tx)
}
/// Handle <Down>.
pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option<HistoryEntry> {
let total_entries = self.history_entry_count + self.local_history.len();
if total_entries == 0 {
return None;
}
let next_idx_opt = match self.history_cursor {
None => return None, // not browsing
Some(idx) if (idx as usize) + 1 >= total_entries => None,
Some(idx) => Some(idx + 1),
};
match next_idx_opt {
Some(idx) => {
self.history_cursor = Some(idx);
self.populate_history_at_index(idx as usize, app_event_tx)
}
None => {
// Past newest – clear and exit browsing mode.
self.history_cursor = None;
self.last_history_text = None;
Some(HistoryEntry::new(String::new()))
}
}
}
/// Integrate a GetHistoryEntryResponse event.
pub fn on_entry_response(
&mut self,
log_id: u64,
offset: usize,
entry: Option<String>,
) -> Option<HistoryEntry> {
if self.history_log_id != Some(log_id) {
return None;
}
let entry = HistoryEntry::new(entry?);
self.fetched_history.insert(offset, entry.clone());
if self.history_cursor == Some(offset as isize) {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
}
None
}
// ---------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------
fn populate_history_at_index(
&mut self,
global_idx: usize,
app_event_tx: &AppEventSender,
) -> Option<HistoryEntry> {
if global_idx >= self.history_entry_count {
// Local entry.
if let Some(entry) = self
.local_history
.get(global_idx - self.history_entry_count)
.cloned()
{
self.last_history_text = Some(entry.text.clone());
return Some(entry);
}
} else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
warn!(
log_id,
offset = global_idx,
"composer history fetch is unavailable in app-server TUI"
);
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(
"Composer history fetch: Not available in app-server TUI yet.".to_string(),
),
)));
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn duplicate_submissions_are_not_recorded() {
let mut history = ChatComposerHistory::new();
// Empty submissions are ignored.
history.record_local_submission(HistoryEntry::new(String::new()));
assert_eq!(history.local_history.len(), 0);
// First entry is recorded.
history.record_local_submission(HistoryEntry::new("hello".to_string()));
assert_eq!(history.local_history.len(), 1);
assert_eq!(
history.local_history.last().unwrap(),
&HistoryEntry::new("hello".to_string())
);
// Identical consecutive entry is skipped.
history.record_local_submission(HistoryEntry::new("hello".to_string()));
assert_eq!(history.local_history.len(), 1);
// Different entry is recorded.
history.record_local_submission(HistoryEntry::new("world".to_string()));
assert_eq!(history.local_history.len(), 2);
assert_eq!(
history.local_history.last().unwrap(),
&HistoryEntry::new("world".to_string())
);
}
#[test]
fn navigation_with_async_fetch() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
// Pretend there are 3 persistent entries.
history.set_metadata(1, 3);
// First Up should request offset 2 (latest) and await async data.
assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that the app-server TUI emits an explicit user-facing stub error instead.
let event = rx.try_recv().expect("expected AppEvent to be sent");
let AppEvent::InsertHistoryCell(cell) = event else {
panic!("unexpected event variant");
};
let rendered = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<String>();
assert!(rendered.contains("Composer history fetch: Not available in app-server TUI yet."));
// Inject the async response.
assert_eq!(
Some(HistoryEntry::new("latest".to_string())),
history.on_entry_response(1, 2, Some("latest".into()))
);
// Next Up should move to offset 1.
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second stub error for offset 1.
let event2 = rx.try_recv().expect("expected second event");
let AppEvent::InsertHistoryCell(cell) = event2 else {
panic!("unexpected event variant");
};
let rendered = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<String>();
assert!(rendered.contains("Composer history fetch: Not available in app-server TUI yet."));
assert_eq!(
Some(HistoryEntry::new("older".to_string())),
history.on_entry_response(1, 1, Some("older".into()))
);
}
#[test]
fn reset_navigation_resets_cursor() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut history = ChatComposerHistory::new();
history.set_metadata(1, 3);
history
.fetched_history
.insert(1, HistoryEntry::new("command2".to_string()));
history
.fetched_history
.insert(2, HistoryEntry::new("command3".to_string()));
assert_eq!(
Some(HistoryEntry::new("command3".to_string())),
history.navigate_up(&tx)
);
assert_eq!(
Some(HistoryEntry::new("command2".to_string())),
history.navigate_up(&tx)
);
history.reset_navigation();
assert!(history.history_cursor.is_none());
assert!(history.last_history_text.is_none());
assert_eq!(
Some(HistoryEntry::new("command3".to_string())),
history.navigate_up(&tx)
);
}
#[test]
fn should_handle_navigation_when_cursor_is_at_line_boundaries() {
let mut history = ChatComposerHistory::new();
history.record_local_submission(HistoryEntry::new("hello".to_string()));
history.last_history_text = Some("hello".to_string());
assert!(history.should_handle_navigation("hello", 0));
assert!(history.should_handle_navigation("hello", "hello".len()));
assert!(!history.should_handle_navigation("hello", 1));
assert!(!history.should_handle_navigation("other", 0));
}
}
@@ -0,0 +1,648 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use super::slash_commands;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
// Hide alias commands in the default popup list so each unique action appears once.
// `quit` is an alias of `exit`, so we skip `quit` here.
// `approvals` is an alias of `permissions`.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CommandItem {
Builtin(SlashCommand),
// Index into `prompts`
UserPrompt(usize),
}
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
fn from(value: CommandPopupFlags) -> Self {
Self {
collaboration_modes_enabled: value.collaboration_modes_enabled,
connectors_enabled: value.connectors_enabled,
fast_command_enabled: value.fast_command_enabled,
personality_command_enabled: value.personality_command_enabled,
realtime_conversation_enabled: value.realtime_conversation_enabled,
audio_device_selection_enabled: value.audio_device_selection_enabled,
allow_elevate_sandbox: value.windows_degraded_sandbox_active,
}
}
}
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(flags.into())
.into_iter()
.filter(|(name, _)| !name.starts_with("debug"))
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
builtins,
prompts,
state: ScrollState::new(),
}
}
#[cfg(test)]
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
self.prompts.get(idx)
}
/// Update the filter string based on the current composer text. The text
/// passed in is expected to start with a leading '/'. Everything after the
/// *first* '/' on the *first* line becomes the active filter that is used
/// to narrow down the list of available commands.
pub(crate) fn on_composer_text_change(&mut self, text: String) {
let first_line = text.lines().next().unwrap_or("");
if let Some(stripped) = first_line.strip_prefix('/') {
// Extract the *first* token (sequence of non-whitespace
// characters) after the slash so that `/clear something` still
// shows the help for `/clear`.
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
// Update the filter keeping the original case (commands are all
// lower-case for now but this may change in the future).
self.command_filter = cmd_token.to_string();
} else {
// The composer no longer starts with '/'. Reset the filter so the
// popup shows the *full* command list if it is still displayed
// for some reason.
self.command_filter.clear();
}
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_items().len();
self.state.clamp_selection(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup for a given width.
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
use super::selection_popup_common::measure_rows_height;
let rows = self.rows_from_matches(self.filtered());
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
}
/// Compute exact/prefix matches over built-in commands and user prompts,
/// paired with optional highlight indices. Preserves the original
/// presentation order for built-ins and prompts.
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>)> {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
if filter.is_empty() {
// Built-ins first, in presentation order.
for (_, cmd) in self.builtins.iter() {
if ALIAS_COMMANDS.contains(cmd) {
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
}
// Then prompts, already sorted by name.
for idx in 0..self.prompts.len() {
out.push((CommandItem::UserPrompt(idx), None));
}
return out;
}
let filter_lower = filter.to_lowercase();
let filter_chars = filter.chars().count();
let mut exact: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
let mut push_match =
|item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| {
let display_lower = display.to_lowercase();
let name_lower = name.map(str::to_lowercase);
let display_exact = display_lower == filter_lower;
let name_exact = name_lower.as_deref() == Some(filter_lower.as_str());
if display_exact || name_exact {
let offset = if display_exact { 0 } else { name_offset };
exact.push((item, indices_for(offset)));
return;
}
let display_prefix = display_lower.starts_with(&filter_lower);
let name_prefix = name_lower
.as_ref()
.is_some_and(|name| name.starts_with(&filter_lower));
if display_prefix || name_prefix {
let offset = if display_prefix { 0 } else { name_offset };
prefix.push((item, indices_for(offset)));
}
};
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
}
// Support both search styles:
// - Typing "name" should surface "/prompts:name" results.
// - Typing "prompts:name" should also work.
for (idx, p) in self.prompts.iter().enumerate() {
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
push_match(
CommandItem::UserPrompt(idx),
&display,
Some(&p.name),
prompt_prefix_len,
);
}
out.extend(exact);
out.extend(prefix);
out
}
fn filtered_items(&self) -> Vec<CommandItem> {
self.filtered().into_iter().map(|(c, _)| c).collect()
}
fn rows_from_matches(
&self,
matches: Vec<(CommandItem, Option<Vec<usize>>)>,
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(item, indices)| {
let (name, description) = match item {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
}
CommandItem::UserPrompt(i) => {
let prompt = &self.prompts[i];
let description = prompt
.description
.clone()
.unwrap_or_else(|| "send saved prompt".to_string());
(
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
description,
)
}
};
GenericDisplayRow {
name,
name_prefix_spans: Vec::new(),
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
display_shortcut: None,
description: Some(description),
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
}
})
.collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
let len = self.filtered_items().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_items().len();
self.state.move_down_wrap(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Return currently selected command, if any.
pub(crate) fn selected_item(&self) -> Option<CommandItem> {
let matches = self.filtered_items();
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered());
render_rows(
area.inset(Insets::tlbr(0, 2, 0, 0)),
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn filter_includes_init_when_typing_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
// Simulate the composer line starting with '/in' so the popup filters
// matching commands by prefix.
popup.on_composer_text_change("/in".to_string());
// Access the filtered list via the selected command and ensure that
// one of the matches is the new "init" command.
let matches = popup.filtered_items();
let has_init = matches.iter().any(|item| match item {
CommandItem::Builtin(cmd) => cmd.command() == "init",
CommandItem::UserPrompt(_) => false,
});
assert!(
has_init,
"expected '/init' to appear among filtered commands"
);
}
#[test]
fn selecting_init_by_exact_match() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/init".to_string());
// When an exact match exists, the selected command should be that
// command by default.
let selected = popup.selected_item();
match selected {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"),
Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"),
None => panic!("expected a selected command for exact match"),
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/m".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![
CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "hello from foo".to_string(),
description: None,
argument_hint: None,
},
CustomPrompt {
name: "bar".to_string(),
path: "/tmp/bar.md".to_string().into(),
content: "hello from bar".to_string(),
description: None,
argument_hint: None,
},
];
let popup = CommandPopup::new(prompts, CommandPopupFlags::default());
let items = popup.filtered_items();
let mut prompt_names: Vec<String> = items
.into_iter()
.filter_map(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
_ => None,
})
.collect();
prompt_names.sort();
assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]);
}
#[test]
fn prompt_name_collision_with_builtin_is_ignored() {
// Create a prompt named like a builtin (e.g. "init").
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "init".to_string(),
path: "/tmp/init.md".to_string().into(),
content: "should be ignored".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
_ => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin name should be ignored"
);
}
#[test]
fn prompt_description_uses_frontmatter_metadata() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "draftpr".to_string(),
path: "/tmp/draftpr.md".to_string().into(),
content: "body".to_string(),
description: Some("Create feature branch, commit and open draft PR.".to_string()),
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(
description,
Some("Create feature branch, commit and open draft PR.")
);
}
#[test]
fn prompt_description_falls_back_when_missing() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "body".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]);
let description = rows.first().and_then(|row| row.description.as_deref());
assert_eq!(description, Some("send saved prompt"));
}
#[test]
fn prefix_filter_limits_matches_for_ac() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/ac".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"compact"),
"expected prefix search for '/ac' to exclude 'compact', got {cmds:?}"
);
}
#[test]
fn quit_hidden_in_empty_filter_but_shown_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let items = popup.filtered_items();
assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
popup.on_composer_text_change("/qu".to_string());
let items = popup.filtered_items();
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
}
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"collab"),
"expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
assert!(
!cmds.contains(&"plan"),
"expected '/plan' to be hidden when collaboration modes are disabled, got {cmds:?}"
);
}
#[test]
fn collab_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/collab".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"),
other => panic!("expected collab to be selected for exact match, got {other:?}"),
}
}
#[test]
fn plan_command_visible_when_collaboration_modes_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/plan".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "plan"),
other => panic!("expected plan to be selected for exact match, got {other:?}"),
}
}
#[test]
fn personality_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
fast_command_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/pers".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"personality"),
"expected '/personality' to be hidden when disabled, got {cmds:?}"
);
}
#[test]
fn personality_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
connectors_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"),
other => panic!("expected personality to be selected for exact match, got {other:?}"),
}
}
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
fast_command_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"settings"),
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
);
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.iter().any(|name| name.starts_with("debug")),
"expected no /debug* command in popup menu, got {cmds:?}"
);
}
}
@@ -0,0 +1,247 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use crate::render::renderable::Renderable;
use super::popup_consts::standard_popup_hint_line;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
title: String,
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl CustomPromptView {
pub(crate) fn new(
title: String,
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
) -> Self {
Self {
title,
placeholder,
context_label,
on_submit,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
}
}
}
impl BottomPaneView for CustomPromptView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let text = self.textarea.text().trim().to_string();
if !text.is_empty() {
(self.on_submit)(text);
self.complete = true;
}
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.textarea.input(key_event);
}
other => {
self.textarea.input(other);
}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
}
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let input_height = self.input_height(area.width);
// Title line
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
// Optional context line
let mut input_y = area.y.saturating_add(1);
if let Some(context_label) = &self.context_label {
let context_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
Paragraph::new(Line::from(spans)).render(context_area, buf);
input_y = input_y.saturating_add(1);
}
// Input line
let input_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(self.placeholder.clone().dim()))
.render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl CustomPromptView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
}
fn gutter() -> Span<'static> {
"▌ ".cyan()
}
@@ -0,0 +1,300 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use codex_core::features::Feature;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct ExperimentalFeatureItem {
pub feature: Feature,
pub name: String,
pub description: String,
pub enabled: bool,
}
pub(crate) struct ExperimentalFeaturesView {
features: Vec<ExperimentalFeatureItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
}
impl ExperimentalFeaturesView {
pub(crate) fn new(
features: Vec<ExperimentalFeatureItem>,
app_event_tx: AppEventSender,
) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle experimental features. Changes are saved to config.toml.".dim(),
));
let mut view = Self {
features,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
};
view.initialize_selection();
view
}
fn initialize_selection(&mut self) {
if self.visible_len() == 0 {
self.state.selected_idx = None;
} else if self.state.selected_idx.is_none() {
self.state.selected_idx = Some(0);
}
}
fn visible_len(&self) -> usize {
self.features.len()
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
let mut rows = Vec::with_capacity(self.features.len());
let selected_idx = self.state.selected_idx;
for (idx, item) in self.features.iter().enumerate() {
let prefix = if selected_idx == Some(idx) {
'›'
} else {
' '
};
let marker = if item.enabled { 'x' } else { ' ' };
let name = format!("{prefix} [{marker}] {}", item.name);
rows.push(GenericDisplayRow {
name,
description: Some(item.description.clone()),
..Default::default()
});
}
rows
}
fn move_up(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn move_down(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn toggle_selected(&mut self) {
let Some(selected_idx) = self.state.selected_idx else {
return;
};
if let Some(item) = self.features.get_mut(selected_idx) {
item.enabled = !item.enabled;
}
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
}
impl BottomPaneView for ExperimentalFeaturesView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
// Save the updates
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
}
self.complete = true;
CancellationEvent::Handled
}
}
impl Renderable for ExperimentalFeaturesView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let [header_area, _, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
self.header.render(header_area, buf);
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows(
render_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
" No experimental features available for now",
);
}
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height.saturating_add(1)
}
}
fn experimental_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to select or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save for next conversation".into(),
])
}
@@ -0,0 +1,777 @@
use std::cell::RefCell;
use std::path::PathBuf;
use codex_feedback::feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME;
use codex_feedback::feedback_diagnostics::FeedbackDiagnostics;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event::FeedbackCategory;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::render::renderable::Renderable;
use codex_protocol::protocol::SessionSource;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::standard_popup_hint_line;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
const BASE_CLI_BUG_ISSUE_URL: &str =
"https://github.com/openai/codex/issues/new?template=3-cli.yml";
/// Internal routing link for employee feedback follow-ups. This must not be shown to external users.
const CODEX_FEEDBACK_INTERNAL_URL: &str = "http://go/codex-feedback-internal";
/// The target audience for feedback follow-up instructions.
///
/// This is used strictly for messaging/links after feedback upload completes. It
/// must not change feedback upload behavior itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FeedbackAudience {
OpenAiEmployee,
External,
}
/// Minimal input overlay to collect an optional feedback note, then upload
/// both logs and rollout with classification + metadata.
pub(crate) struct FeedbackNoteView {
category: FeedbackCategory,
snapshot: codex_feedback::FeedbackSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl FeedbackNoteView {
pub(crate) fn new(
category: FeedbackCategory,
snapshot: codex_feedback::FeedbackSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
feedback_audience: FeedbackAudience,
) -> Self {
Self {
category,
snapshot,
rollout_path,
app_event_tx,
include_logs,
feedback_audience,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
}
}
fn submit(&mut self) {
let note = self.textarea.text().trim().to_string();
let reason_opt = if note.is_empty() {
None
} else {
Some(note.as_str())
};
let attachment_paths = if self.include_logs {
self.rollout_path.iter().cloned().collect::<Vec<_>>()
} else {
Vec::new()
};
let classification = feedback_classification(self.category);
let mut thread_id = self.snapshot.thread_id.clone();
let result = self.snapshot.upload_feedback(
classification,
reason_opt,
self.include_logs,
&attachment_paths,
Some(SessionSource::Cli),
None,
);
match result {
Ok(()) => {
let prefix = if self.include_logs {
"• Feedback uploaded."
} else {
"• Feedback recorded (no logs)."
};
let issue_url =
issue_url_for_category(self.category, &thread_id, self.feedback_audience);
let mut lines = vec![Line::from(match issue_url.as_ref() {
Some(_) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => {
format!("{prefix} Please report this in #codex-feedback:")
}
Some(_) => format!("{prefix} Please open an issue using the following URL:"),
None => format!("{prefix} Thanks for the feedback!"),
})];
match issue_url {
Some(url) if self.feedback_audience == FeedbackAudience::OpenAiEmployee => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(" Share this and add some info about your problem:"),
Line::from(vec![
" ".into(),
format!("https://go/codex-feedback/{thread_id}").bold(),
]),
]);
}
Some(url) => {
lines.extend([
"".into(),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
std::mem::take(&mut thread_id).bold(),
" in an existing issue.".into(),
]),
]);
}
None => {
lines.extend([
"".into(),
Line::from(vec![
" Thread ID: ".into(),
std::mem::take(&mut thread_id).bold(),
]),
]);
}
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::PlainHistoryCell::new(lines),
)));
}
Err(e) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(format!("Failed to upload feedback: {e}")),
)));
}
}
self.complete = true;
}
}
impl BottomPaneView for FeedbackNoteView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
self.submit();
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.textarea.input(key_event);
}
other => {
self.textarea.input(other);
}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
}
impl Renderable for FeedbackNoteView {
fn desired_height(&self, width: u16) -> u16 {
self.intro_lines(width).len() as u16 + self.input_height(width) + 2u16
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let intro_height = self.intro_lines(area.width).len() as u16;
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(intro_height).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let intro_lines = self.intro_lines(area.width);
let (_, placeholder) = feedback_title_and_placeholder(self.category);
let input_height = self.input_height(area.width);
for (offset, line) in intro_lines.iter().enumerate() {
Paragraph::new(line.clone()).render(
Rect {
x: area.x,
y: area.y.saturating_add(offset as u16),
width: area.width,
height: 1,
},
buf,
);
}
// Input line
let input_area = Rect {
x: area.x,
y: area.y.saturating_add(intro_lines.len() as u16),
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
}
}
}
impl FeedbackNoteView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
fn intro_lines(&self, _width: u16) -> Vec<Line<'static>> {
let (title, _) = feedback_title_and_placeholder(self.category);
vec![Line::from(vec![gutter(), title.bold()])]
}
}
pub(crate) fn should_show_feedback_connectivity_details(
category: FeedbackCategory,
diagnostics: &FeedbackDiagnostics,
) -> bool {
category != FeedbackCategory::GoodResult && !diagnostics.is_empty()
}
fn gutter() -> Span<'static> {
"▌ ".cyan()
}
fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) {
match category {
FeedbackCategory::BadResult => (
"Tell us more (bad result)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::GoodResult => (
"Tell us more (good result)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::Bug => (
"Tell us more (bug)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::SafetyCheck => (
"Tell us more (safety check)".to_string(),
"(optional) Share what was refused and why it should have been allowed".to_string(),
),
FeedbackCategory::Other => (
"Tell us more (other)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
}
}
fn feedback_classification(category: FeedbackCategory) -> &'static str {
match category {
FeedbackCategory::BadResult => "bad_result",
FeedbackCategory::GoodResult => "good_result",
FeedbackCategory::Bug => "bug",
FeedbackCategory::SafetyCheck => "safety_check",
FeedbackCategory::Other => "other",
}
}
fn issue_url_for_category(
category: FeedbackCategory,
thread_id: &str,
feedback_audience: FeedbackAudience,
) -> Option<String> {
// Only certain categories provide a follow-up link. We intentionally keep
// the external GitHub behavior identical while routing internal users to
// the internal go link.
match category {
FeedbackCategory::Bug
| FeedbackCategory::BadResult
| FeedbackCategory::SafetyCheck
| FeedbackCategory::Other => Some(match feedback_audience {
FeedbackAudience::OpenAiEmployee => slack_feedback_url(thread_id),
FeedbackAudience::External => {
format!("{BASE_CLI_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}")
}
}),
FeedbackCategory::GoodResult => None,
}
}
/// Build the internal follow-up URL.
///
/// We accept a `thread_id` so the call site stays symmetric with the external
/// path, but we currently point to a fixed channel without prefilling text.
fn slack_feedback_url(_thread_id: &str) -> String {
CODEX_FEEDBACK_INTERNAL_URL.to_string()
}
// Build the selection popup params for feedback categories.
pub(crate) fn feedback_selection_params(
app_event_tx: AppEventSender,
) -> super::SelectionViewParams {
super::SelectionViewParams {
title: Some("How was this?".to_string()),
items: vec![
make_feedback_item(
app_event_tx.clone(),
"bug",
"Crash, error message, hang, or broken UI/behavior.",
FeedbackCategory::Bug,
),
make_feedback_item(
app_event_tx.clone(),
"bad result",
"Output was off-target, incorrect, incomplete, or unhelpful.",
FeedbackCategory::BadResult,
),
make_feedback_item(
app_event_tx.clone(),
"good result",
"Helpful, correct, high‑quality, or delightful result worth celebrating.",
FeedbackCategory::GoodResult,
),
make_feedback_item(
app_event_tx.clone(),
"safety check",
"Benign usage blocked due to safety checks or refusals.",
FeedbackCategory::SafetyCheck,
),
make_feedback_item(
app_event_tx,
"other",
"Slowness, feature suggestion, UX feedback, or anything else.",
FeedbackCategory::Other,
),
],
..Default::default()
}
}
/// Build the selection popup params shown when feedback is disabled.
pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams {
super::SelectionViewParams {
title: Some("Sending feedback is disabled".to_string()),
subtitle: Some("This action is disabled by configuration.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items: vec![super::SelectionItem {
name: "Close".to_string(),
dismiss_on_select: true,
..Default::default()
}],
..Default::default()
}
}
fn make_feedback_item(
app_event_tx: AppEventSender,
name: &str,
description: &str,
category: FeedbackCategory,
) -> super::SelectionItem {
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
});
super::SelectionItem {
name: name.to_string(),
description: Some(description.to_string()),
actions: vec![action],
dismiss_on_select: true,
..Default::default()
}
}
/// Build the upload consent popup params for a given feedback category.
pub(crate) fn feedback_upload_consent_params(
app_event_tx: AppEventSender,
category: FeedbackCategory,
rollout_path: Option<std::path::PathBuf>,
feedback_diagnostics: &FeedbackDiagnostics,
) -> super::SelectionViewParams {
use super::popup_consts::standard_popup_hint_line;
let yes_action: super::SelectionAction = Box::new({
let tx = app_event_tx.clone();
move |sender: &AppEventSender| {
let _ = sender;
tx.send(AppEvent::OpenFeedbackNote {
category,
include_logs: true,
});
}
});
let no_action: super::SelectionAction = Box::new({
let tx = app_event_tx;
move |sender: &AppEventSender| {
let _ = sender;
tx.send(AppEvent::OpenFeedbackNote {
category,
include_logs: false,
});
}
});
// Build header listing files that would be sent if user consents.
let mut header_lines: Vec<Box<dyn crate::render::renderable::Renderable>> = vec![
Line::from("Upload logs?".bold()).into(),
Line::from("").into(),
Line::from("The following files will be sent:".dim()).into(),
Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(),
];
if let Some(path) = rollout_path.as_deref()
&& let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string())
{
header_lines.push(Line::from(vec![" • ".into(), name.into()]).into());
}
if !feedback_diagnostics.is_empty() {
header_lines.push(
Line::from(vec![
" • ".into(),
FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME.into(),
])
.into(),
);
}
if should_show_feedback_connectivity_details(category, feedback_diagnostics) {
header_lines.push(Line::from("").into());
header_lines.push(Line::from("Connectivity diagnostics".bold()).into());
for diagnostic in feedback_diagnostics.diagnostics() {
header_lines
.push(Line::from(vec![" - ".into(), diagnostic.headline.clone().into()]).into());
for detail in &diagnostic.details {
header_lines.push(Line::from(vec![" - ".dim(), detail.clone().into()]).into());
}
}
}
super::SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items: vec![
super::SelectionItem {
name: "Yes".to_string(),
description: Some(
"Share the current Codex session logs with the team for troubleshooting."
.to_string(),
),
actions: vec![yes_action],
dismiss_on_select: true,
..Default::default()
},
super::SelectionItem {
name: "No".to_string(),
actions: vec![no_action],
dismiss_on_select: true,
..Default::default()
},
],
header: Box::new(crate::render::renderable::ColumnRenderable::with(
header_lines,
)),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_feedback::feedback_diagnostics::FeedbackDiagnostic;
use pretty_assertions::assert_eq;
fn render(view: &FeedbackNoteView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let mut lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line.trim_end().to_string()
})
.collect();
while lines.first().is_some_and(|l| l.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
lines.join("\n")
}
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let snapshot = codex_feedback::CodexFeedback::new().snapshot(None);
FeedbackNoteView::new(
category,
snapshot,
None,
tx,
true,
FeedbackAudience::External,
)
}
#[test]
fn feedback_view_bad_result() {
let view = make_view(FeedbackCategory::BadResult);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_bad_result", rendered);
}
#[test]
fn feedback_view_good_result() {
let view = make_view(FeedbackCategory::GoodResult);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_good_result", rendered);
}
#[test]
fn feedback_view_bug() {
let view = make_view(FeedbackCategory::Bug);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_bug", rendered);
}
#[test]
fn feedback_view_other() {
let view = make_view(FeedbackCategory::Other);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_other", rendered);
}
#[test]
fn feedback_view_safety_check() {
let view = make_view(FeedbackCategory::SafetyCheck);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_safety_check", rendered);
}
#[test]
fn feedback_view_with_connectivity_diagnostics() {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let diagnostics = FeedbackDiagnostics::new(vec![
FeedbackDiagnostic {
headline: "Proxy environment variables are set and may affect connectivity."
.to_string(),
details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()],
},
FeedbackDiagnostic {
headline: "OPENAI_BASE_URL is set and may affect connectivity.".to_string(),
details: vec!["OPENAI_BASE_URL = https://example.com/v1".to_string()],
},
]);
let snapshot = codex_feedback::CodexFeedback::new()
.snapshot(None)
.with_feedback_diagnostics(diagnostics);
let view = FeedbackNoteView::new(
FeedbackCategory::Bug,
snapshot,
None,
tx,
false,
FeedbackAudience::External,
);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered);
}
#[test]
fn should_show_feedback_connectivity_details_only_for_non_good_result_with_diagnostics() {
let diagnostics = FeedbackDiagnostics::new(vec![FeedbackDiagnostic {
headline: "Proxy environment variables are set and may affect connectivity."
.to_string(),
details: vec!["HTTP_PROXY = http://proxy.example.com:8080".to_string()],
}]);
assert_eq!(
should_show_feedback_connectivity_details(FeedbackCategory::Bug, &diagnostics),
true
);
assert_eq!(
should_show_feedback_connectivity_details(FeedbackCategory::GoodResult, &diagnostics),
false
);
assert_eq!(
should_show_feedback_connectivity_details(
FeedbackCategory::BadResult,
&FeedbackDiagnostics::default()
),
false
);
}
#[test]
fn issue_url_available_for_bug_bad_result_safety_check_and_other() {
let bug_url = issue_url_for_category(
FeedbackCategory::Bug,
"thread-1",
FeedbackAudience::OpenAiEmployee,
);
let expected_slack_url = "http://go/codex-feedback-internal".to_string();
assert_eq!(bug_url.as_deref(), Some(expected_slack_url.as_str()));
let bad_result_url = issue_url_for_category(
FeedbackCategory::BadResult,
"thread-2",
FeedbackAudience::OpenAiEmployee,
);
assert!(bad_result_url.is_some());
let other_url = issue_url_for_category(
FeedbackCategory::Other,
"thread-3",
FeedbackAudience::OpenAiEmployee,
);
assert!(other_url.is_some());
let safety_check_url = issue_url_for_category(
FeedbackCategory::SafetyCheck,
"thread-4",
FeedbackAudience::OpenAiEmployee,
);
assert!(safety_check_url.is_some());
assert!(
issue_url_for_category(
FeedbackCategory::GoodResult,
"t",
FeedbackAudience::OpenAiEmployee
)
.is_none()
);
let bug_url_non_employee =
issue_url_for_category(FeedbackCategory::Bug, "t", FeedbackAudience::External);
let expected_external_url = "https://github.com/openai/codex/issues/new?template=3-cli.yml&steps=Uploaded%20thread:%20t";
assert_eq!(bug_url_non_employee.as_deref(), Some(expected_external_url));
}
}
@@ -0,0 +1,152 @@
use std::path::PathBuf;
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
/// Query corresponding to the `matches` currently shown.
display_query: String,
/// Latest query typed by the user. May differ from `display_query` when
/// a search is still in-flight.
pending_query: String,
/// When `true` we are still waiting for results for `pending_query`.
waiting: bool,
/// Cached matches; paths relative to the search dir.
matches: Vec<FileMatch>,
/// Shared selection/scroll state.
state: ScrollState,
}
impl FileSearchPopup {
pub(crate) fn new() -> Self {
Self {
display_query: String::new(),
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
state: ScrollState::new(),
}
}
/// Update the query and reset state to *waiting*.
pub(crate) fn set_query(&mut self, query: &str) {
if query == self.pending_query {
return;
}
self.pending_query.clear();
self.pending_query.push_str(query);
self.waiting = true; // waiting for new results
}
/// Put the popup into an "idle" state used for an empty query (just "@").
/// Shows a hint instead of matches until the user types more characters.
pub(crate) fn set_empty_prompt(&mut self) {
self.display_query.clear();
self.pending_query.clear();
self.waiting = false;
self.matches.clear();
// Reset selection/scroll state when showing the empty prompt.
self.state.reset();
}
/// Replace matches when a `FileSearchResult` arrives.
/// Replace matches. Only applied when `query` matches `pending_query`.
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
if query != self.pending_query {
return; // stale
}
self.display_query = query.to_string();
self.matches = matches;
self.waiting = false;
let len = self.matches.len();
self.state.clamp_selection(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor up.
pub(crate) fn move_up(&mut self) {
let len = self.matches.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor down.
pub(crate) fn move_down(&mut self) {
let len = self.matches.len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub(crate) fn selected_match(&self) -> Option<&PathBuf> {
self.state
.selected_idx
.and_then(|idx| self.matches.get(idx))
.map(|file_match| &file_match.path)
}
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
// row so the popup is still visible. When matches are present we show
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
}
}
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
Vec::new()
} else {
self.matches
.iter()
.map(|m| GenericDisplayRow {
name: m.path.to_string_lossy().to_string(),
name_prefix_spans: Vec::new(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
display_shortcut: None,
description: None,
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
})
.collect()
};
let empty_message = if self.waiting {
"loading..."
} else {
"no matches"
};
render_rows(
area.inset(Insets::tlbr(0, 2, 0, 0)),
buf,
&rows_all,
&self.state,
MAX_POPUP_ROWS,
empty_message,
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,795 @@
//! Multi-select picker widget for selecting multiple items from a list.
//!
//! This module provides a fuzzy-searchable, scrollable picker that allows users
//! to toggle multiple items on/off. It supports:
//!
//! - **Fuzzy search**: Type to filter items by name
//! - **Toggle selection**: Space to toggle items on/off
//! - **Reordering**: Optional left/right arrow support to reorder items
//! - **Live preview**: Optional callback to show a preview of current selections
//! - **Callbacks**: Hooks for change, confirm, and cancel events
//!
//! # Example
//!
//! ```ignore
//! let picker = MultiSelectPicker::new(
//! "Select Items".to_string(),
//! Some("Choose which items to enable".to_string()),
//! app_event_tx,
//! )
//! .items(vec![
//! MultiSelectItem { id: "a".into(), name: "Item A".into(), description: None, enabled: true },
//! MultiSelectItem { id: "b".into(), name: "Item B".into(), description: None, enabled: false },
//! ])
//! .on_confirm(|selected_ids, tx| { /* handle confirmation */ })
//! .build();
//! ```
use codex_utils_fuzzy_match::fuzzy_match;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use super::selection_popup_common::GenericDisplayRow;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::text_formatting::truncate_text;
/// Maximum display length for item names before truncation.
const ITEM_NAME_TRUNCATE_LEN: usize = 21;
/// Placeholder text shown in the search input when empty.
const SEARCH_PLACEHOLDER: &str = "Type to search";
/// Prefix displayed before the search query (mimics a command prompt).
const SEARCH_PROMPT_PREFIX: &str = "> ";
/// Direction for reordering items in the list.
enum Direction {
Up,
Down,
}
/// Callback invoked when any item's state changes (toggled or reordered).
/// Receives the full list of items and the event sender.
pub type ChangeCallBack = Box<dyn Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync>;
/// Callback invoked when the user confirms their selection (presses Enter).
/// Receives a list of IDs for all enabled items.
pub type ConfirmCallback = Box<dyn Fn(&[String], &AppEventSender) + Send + Sync>;
/// Callback invoked when the user cancels the picker (presses Escape).
pub type CancelCallback = Box<dyn Fn(&AppEventSender) + Send + Sync>;
/// Callback to generate an optional preview line based on current item states.
/// Returns `None` to hide the preview area.
pub type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync>;
/// A single selectable item in the multi-select picker.
///
/// Each item has a unique identifier, display name, optional description,
/// and an enabled/disabled state that can be toggled by the user.
#[derive(Default)]
pub(crate) struct MultiSelectItem {
/// Unique identifier returned in the confirm callback when this item is enabled.
pub id: String,
/// Display name shown in the picker list. Will be truncated if too long.
pub name: String,
/// Optional description shown alongside the name (dimmed).
pub description: Option<String>,
/// Whether this item is currently selected/enabled.
pub enabled: bool,
}
/// A multi-select picker widget with fuzzy search and optional reordering.
///
/// The picker displays a scrollable list of items with checkboxes. Users can:
/// - Type to fuzzy-search and filter the list
/// - Use Up/Down (or Ctrl+P/Ctrl+N) to navigate
/// - Press Space to toggle the selected item
/// - Press Enter to confirm and close
/// - Press Escape to cancel and close
/// - Use Left/Right arrows to reorder items (if ordering is enabled)
///
/// Create instances using the builder pattern via [`MultiSelectPicker::new`].
pub(crate) struct MultiSelectPicker {
/// All items in the picker (unfiltered).
items: Vec<MultiSelectItem>,
/// Scroll and selection state for the visible list.
state: ScrollState,
/// Whether the picker has been closed (confirmed or cancelled).
pub(crate) complete: bool,
/// Channel for sending application events.
app_event_tx: AppEventSender,
/// Header widget displaying title and subtitle.
header: Box<dyn Renderable>,
/// Footer line showing keyboard hints.
footer_hint: Line<'static>,
/// Current search/filter query entered by the user.
search_query: String,
/// Indices into `items` that match the current filter, in display order.
filtered_indices: Vec<usize>,
/// Whether left/right arrow reordering is enabled.
ordering_enabled: bool,
/// Optional callback to generate a preview line from current item states.
preview_builder: Option<PreviewCallback>,
/// Cached preview line (updated on item changes).
preview_line: Option<Line<'static>>,
/// Callback invoked when items change (toggle or reorder).
on_change: Option<ChangeCallBack>,
/// Callback invoked when the user confirms their selection.
on_confirm: Option<ConfirmCallback>,
/// Callback invoked when the user cancels the picker.
on_cancel: Option<CancelCallback>,
}
impl MultiSelectPicker {
/// Creates a new builder for constructing a `MultiSelectPicker`.
///
/// # Arguments
///
/// * `title` - The main title displayed at the top of the picker
/// * `subtitle` - Optional subtitle displayed below the title (dimmed)
/// * `app_event_tx` - Event sender for dispatching application events
pub fn builder(
title: String,
subtitle: Option<String>,
app_event_tx: AppEventSender,
) -> MultiSelectPickerBuilder {
MultiSelectPickerBuilder::new(title, subtitle, app_event_tx)
}
/// Applies the current search query to filter and sort items.
///
/// Updates `filtered_indices` to contain only matching items, sorted by
/// fuzzy match score. Attempts to preserve the current selection if it
/// still matches the filter.
fn apply_filter(&mut self) {
// Filter + sort while preserving the current selection when possible.
let previously_selected = self
.state
.selected_idx
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied());
let filter = self.search_query.trim();
if filter.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let mut matches: Vec<(usize, i32)> = Vec::new();
for (idx, item) in self.items.iter().enumerate() {
let display_name = item.name.as_str();
if let Some((_indices, score)) = match_item(filter, display_name, &item.name) {
matches.push((idx, score));
}
}
matches.sort_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
let an = self.items[a.0].name.as_str();
let bn = self.items[b.0].name.as_str();
an.cmp(bn)
})
});
self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect();
}
let len = self.filtered_indices.len();
self.state.selected_idx = previously_selected
.and_then(|actual_idx| {
self.filtered_indices
.iter()
.position(|idx| *idx == actual_idx)
})
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
}
/// Returns the number of items visible after filtering.
fn visible_len(&self) -> usize {
self.filtered_indices.len()
}
/// Returns the maximum number of rows that can be displayed at once.
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
}
/// Calculates the width available for row content (accounts for borders).
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
/// Calculates the height needed for the row list area.
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
}
/// Builds the display rows for all currently visible (filtered) items.
///
/// Each row shows: `› [x] Item Name` where `›` indicates cursor position
/// and `[x]` or `[ ]` indicates enabled/disabled state.
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '›' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_text(&item.name, ITEM_NAME_TRUNCATE_LEN);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: item.description.clone(),
..Default::default()
}
})
})
.collect()
}
/// Moves the selection cursor up, wrapping to the bottom if at the top.
fn move_up(&mut self) {
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
/// Moves the selection cursor down, wrapping to the top if at the bottom.
fn move_down(&mut self) {
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
/// Toggles the enabled state of the currently selected item.
///
/// Updates the preview line and invokes the `on_change` callback if set.
fn toggle_selected(&mut self) {
let Some(idx) = self.state.selected_idx else {
return;
};
let Some(actual_idx) = self.filtered_indices.get(idx).copied() else {
return;
};
let Some(item) = self.items.get_mut(actual_idx) else {
return;
};
item.enabled = !item.enabled;
self.update_preview_line();
if let Some(on_change) = &self.on_change {
on_change(&self.items, &self.app_event_tx);
}
}
/// Confirms the current selection and closes the picker.
///
/// Collects the IDs of all enabled items and passes them to the
/// `on_confirm` callback. Does nothing if already complete.
fn confirm_selection(&mut self) {
if self.complete {
return;
}
self.complete = true;
if let Some(on_confirm) = &self.on_confirm {
let selected_ids: Vec<String> = self
.items
.iter()
.filter(|item| item.enabled)
.map(|item| item.id.clone())
.collect();
on_confirm(&selected_ids, &self.app_event_tx);
}
}
/// Moves the currently selected item up or down in the list.
///
/// Only works when:
/// - The search query is empty (reordering is disabled during filtering)
/// - Ordering is enabled via [`MultiSelectPickerBuilder::enable_ordering`]
///
/// Updates the preview line and invokes the `on_change` callback.
fn move_selected_item(&mut self, direction: Direction) {
if !self.search_query.is_empty() {
return;
}
let Some(visible_idx) = self.state.selected_idx else {
return;
};
let Some(actual_idx) = self.filtered_indices.get(visible_idx).copied() else {
return;
};
let len = self.items.len();
if len == 0 {
return;
}
let new_idx = match direction {
Direction::Up if actual_idx > 0 => actual_idx - 1,
Direction::Down if actual_idx + 1 < len => actual_idx + 1,
_ => return,
};
// move item in underlying list
self.items.swap(actual_idx, new_idx);
self.update_preview_line();
if let Some(on_change) = &self.on_change {
on_change(&self.items, &self.app_event_tx);
}
// rebuild filtered indices to keep search/filter consistent
self.apply_filter();
// restore selection to moved item
let moved_idx = new_idx;
if let Some(new_visible_idx) = self
.filtered_indices
.iter()
.position(|idx| *idx == moved_idx)
{
self.state.selected_idx = Some(new_visible_idx);
}
}
/// Regenerates the preview line using the preview callback.
///
/// Called after any item state change (toggle or reorder).
fn update_preview_line(&mut self) {
self.preview_line = self
.preview_builder
.as_ref()
.and_then(|builder| builder(&self.items));
}
/// Closes the picker without confirming, invoking the `on_cancel` callback.
///
/// Does nothing if already complete.
pub fn close(&mut self) {
if self.complete {
return;
}
self.complete = true;
if let Some(on_cancel) = &self.on_cancel {
on_cancel(&self.app_event_tx);
}
}
}
impl BottomPaneView for MultiSelectPicker {
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.close();
CancellationEvent::Handled
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Left, .. } if self.ordering_enabled => {
self.move_selected_item(Direction::Up);
}
KeyEvent { code: KeyCode::Right, .. } if self.ordering_enabled => {
self.move_selected_item(Direction::Down);
}
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
} => {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Enter,
..
} => self.confirm_selection(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.close();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.search_query.push(c);
self.apply_filter();
}
_ => {}
}
}
}
impl Renderable for MultiSelectPicker {
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_height = self.rows_height(&rows);
let preview_height = if self.preview_line.is_some() { 1 } else { 0 };
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1 + preview_height)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
// Reserve the footer line for the key-hint row.
let preview_height = if self.preview_line.is_some() { 1 } else { 0 };
let footer_height = 1 + preview_height;
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_height)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
self.search_query.clone().into()
};
Line::from(query_span).render(search_area, buf);
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows_single_line(
render_area,
buf,
&rows,
&self.state,
render_area.height as usize,
"no matches",
);
}
let hint_area = if let Some(preview_line) = &self.preview_line {
let [preview_area, hint_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
let preview_area = Rect {
x: preview_area.x + 2,
y: preview_area.y,
width: preview_area.width.saturating_sub(2),
height: preview_area.height,
};
let max_preview_width = preview_area.width.saturating_sub(2) as usize;
let preview_line =
truncate_line_with_ellipsis_if_overflow(preview_line.clone(), max_preview_width);
preview_line.render(preview_area, buf);
hint_area
} else {
footer_area
};
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
}
/// Builder for constructing a [`MultiSelectPicker`] with a fluent API.
///
/// # Example
///
/// ```ignore
/// let picker = MultiSelectPicker::new("Title".into(), None, tx)
/// .items(items)
/// .enable_ordering()
/// .on_preview(|items| Some(Line::from("Preview")))
/// .on_confirm(|ids, tx| { /* handle */ })
/// .on_cancel(|tx| { /* handle */ })
/// .build();
/// ```
pub(crate) struct MultiSelectPickerBuilder {
title: String,
subtitle: Option<String>,
instructions: Vec<Span<'static>>,
items: Vec<MultiSelectItem>,
ordering_enabled: bool,
app_event_tx: AppEventSender,
preview_builder: Option<PreviewCallback>,
on_change: Option<ChangeCallBack>,
on_confirm: Option<ConfirmCallback>,
on_cancel: Option<CancelCallback>,
}
impl MultiSelectPickerBuilder {
/// Creates a new builder with the given title, optional subtitle, and event sender.
pub fn new(title: String, subtitle: Option<String>, app_event_tx: AppEventSender) -> Self {
Self {
title,
subtitle,
instructions: Vec::new(),
items: Vec::new(),
ordering_enabled: false,
app_event_tx,
preview_builder: None,
on_change: None,
on_confirm: None,
on_cancel: None,
}
}
/// Sets the list of selectable items.
pub fn items(mut self, items: Vec<MultiSelectItem>) -> Self {
self.items = items;
self
}
/// Sets custom instruction spans for the footer hint line.
///
/// If not set, default instructions are shown (Space to toggle, Enter to
/// confirm, Escape to close).
pub fn instructions(mut self, instructions: Vec<Span<'static>>) -> Self {
self.instructions = instructions;
self
}
/// Enables left/right arrow keys for reordering items.
///
/// Reordering is only active when the search query is empty.
pub fn enable_ordering(mut self) -> Self {
self.ordering_enabled = true;
self
}
/// Sets a callback to generate a preview line from the current item states.
///
/// The callback receives all items and should return a [`Line`] to display,
/// or `None` to hide the preview area.
pub fn on_preview<F>(mut self, callback: F) -> Self
where
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static,
{
self.preview_builder = Some(Box::new(callback));
self
}
/// Sets a callback invoked whenever an item's state changes.
///
/// This includes both toggles and reordering operations.
#[allow(dead_code)]
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(&[MultiSelectItem], &AppEventSender) + Send + Sync + 'static,
{
self.on_change = Some(Box::new(callback));
self
}
/// Sets a callback invoked when the user confirms their selection (Enter).
///
/// The callback receives a list of IDs for all enabled items.
pub fn on_confirm<F>(mut self, callback: F) -> Self
where
F: Fn(&[String], &AppEventSender) + Send + Sync + 'static,
{
self.on_confirm = Some(Box::new(callback));
self
}
/// Sets a callback invoked when the user cancels the picker (Escape).
pub fn on_cancel<F>(mut self, callback: F) -> Self
where
F: Fn(&AppEventSender) + Send + Sync + 'static,
{
self.on_cancel = Some(Box::new(callback));
self
}
/// Builds the [`MultiSelectPicker`] with all configured options.
///
/// Initializes the filter to show all items and generates the initial
/// preview line if a preview callback was set.
pub fn build(self) -> MultiSelectPicker {
let mut header = ColumnRenderable::new();
header.push(Line::from(self.title.bold()));
if let Some(subtitle) = self.subtitle {
header.push(Line::from(subtitle.dim()));
}
let instructions = if self.instructions.is_empty() {
vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm and close; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]
} else {
self.instructions
};
let mut view = MultiSelectPicker {
items: self.items,
state: ScrollState::new(),
complete: false,
app_event_tx: self.app_event_tx,
header: Box::new(header),
footer_hint: Line::from(instructions),
ordering_enabled: self.ordering_enabled,
search_query: String::new(),
filtered_indices: Vec::new(),
preview_builder: self.preview_builder,
preview_line: None,
on_change: self.on_change,
on_confirm: self.on_confirm,
on_cancel: self.on_cancel,
};
view.apply_filter();
view.update_preview_line();
view
}
}
/// Performs fuzzy matching on an item against a filter string.
///
/// Tries to match against the display name first, then falls back to name if different. Returns
/// the matching character indices (if matched on display name) and a score for sorting.
///
/// # Arguments
///
/// * `filter` - The search query to match against
/// * `display_name` - The primary name to match (shown to user)
/// * `name` - A secondary/canonical name to try if display name doesn't match
///
/// # Returns
///
/// * `Some((Some(indices), score))` - Matched on display name with highlight indices
/// * `Some((None, score))` - Matched on skill name only (no highlights for display)
/// * `None` - No match
pub(crate) fn match_item(
filter: &str,
display_name: &str,
name: &str,
) -> Option<(Option<Vec<usize>>, i32)> {
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
return Some((Some(indices), score));
}
if display_name != name
&& let Some((_indices, score)) = fuzzy_match(name, filter)
{
return Some((None, score));
}
None
}
@@ -0,0 +1,572 @@
//! Paste-burst detection for terminals without bracketed paste.
//!
//! On some platforms (notably Windows), pastes often arrive as a rapid stream of
//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event.
//! In that mode, the composer needs to:
//!
//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text.
//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message".
//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as
//! paste once enough chars have arrived.
//!
//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain"
//! character events (no Ctrl/Alt) and uses its decisions to either:
//!
//! - briefly hold a first ASCII char (flicker suppression),
//! - buffer a burst as a single pasted string, or
//! - let input flow through as normal typing.
//!
//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see
//! `docs/tui-chat-composer.md`.
//!
//! # Call Pattern
//!
//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds
//! it events and then applies the chosen action:
//!
//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or
//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME).
//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via
//! [`PasteBurst::append_char_to_buffer`].
//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert
//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as
//! an explicit paste.
//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use
//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then
//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a
//! previous burst.
//!
//! # State Variables
//!
//! This state machine is encoded in a few fields with slightly different meanings:
//!
//! - `active`: true while we are still *actively* accepting characters into the current burst.
//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`.
//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared.
//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must
//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or
//! flushes as a normal typed char (`FlushResult::Typed`).
//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for
//! "paste-like" streams.
//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the
//! buffer itself.
//!
//! # Timing Model
//!
//! There are two timeouts:
//!
//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be
//! considered part of a single burst. It also bounds how long `pending_first_char` is held.
//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last
//! char before flushing the accumulated buffer as a paste.
//!
//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI
//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`).
//!
//! # Retro Capture Details
//!
//! Retro-capture exists to handle the case where we initially inserted characters as "normal
//! typing", but later decide that the stream is paste-like. When that happens, we retroactively
//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so
//! the eventual `handle_paste(...)` sees a contiguous pasted string.
//!
//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME
//! input, and retro-grab scenarios). The ASCII path usually prefers
//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all.
//!
//! Retro-capture is expressed in terms of characters, not bytes:
//!
//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count.
//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by
//! calling `retro_start_index()`.
//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the
//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8.
//!
//! # Clearing vs Flushing
//!
//! There are two ways callers end burst handling, and they are not interchangeable:
//!
//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char)
//! so the caller can apply it through the normal paste path before handling an unrelated input.
//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does
//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer
//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a
//! non-empty buffer until another plain char updates the timestamp.
//!
//! # States (Conceptually)
//!
//! - **Idle**: no buffered text, no pending char.
//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to
//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows.
//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes.
//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after
//! burst activity so multiline pastes stay grouped.
//!
//! # ASCII vs Non-ASCII
//!
//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first
//! ASCII char and avoid flicker.
//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since
//! holding a non-ASCII character can feel like dropped input.
//!
//! # Contract With `ChatComposer`
//!
//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must
//! interpret decisions and apply the corresponding UI edits:
//!
//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`].
//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet.
//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the
//! current char (the previously-held char is already in the burst buffer).
//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted
//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the
//! returned `start_byte..cursor` range from the textarea and then call
//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back
//! to normal insertion.
//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`].
//!
//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then:
//! - If it returns `Some(CharDecision::BufferAppend)`, call
//! [`PasteBurst::append_char_to_buffer`].
//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call
//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed
//! prefix from the textarea and then append the current char to the buffer).
//! - If it returns `None`, insert normally.
//!
//! - Before applying non-char input (or any input that should not join a burst), call
//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the
//! normal paste path.
//!
//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`].
//! - [`FlushResult::Typed`]: insert that single char as normal typing.
//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste.
//!
//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use
//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being
//! incorrectly grouped into a previous burst.
use std::time::Duration;
use std::time::Instant;
// Heuristic thresholds for detecting paste-like input bursts.
// Detect quickly to avoid showing typed prefix before paste is recognized
const PASTE_BURST_MIN_CHARS: u16 = 3;
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
// Maximum delay between consecutive chars to be considered part of a paste burst.
// Windows terminals (especially VS Code integrated terminal) deliver paste events
// more slowly than native terminals, so we use a higher threshold there.
#[cfg(not(windows))]
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(30);
// Idle timeout before flushing buffered paste content.
// Slower paste bursts have been observed in Windows environments.
#[cfg(not(windows))]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
#[derive(Default)]
pub(crate) struct PasteBurst {
last_plain_char_time: Option<Instant>,
consecutive_plain_char_burst: u16,
burst_window_until: Option<Instant>,
buffer: String,
active: bool,
// Hold first fast char briefly to avoid rendering flicker
pending_first_char: Option<(char, Instant)>,
}
pub(crate) enum CharDecision {
/// Start buffering and retroactively capture some already-inserted chars.
BeginBuffer { retro_chars: u16 },
/// We are currently buffering; append the current char into the buffer.
BufferAppend,
/// Do not insert/render this char yet; temporarily save the first fast
/// char while we wait to see if a paste-like burst follows.
RetainFirstChar,
/// Begin buffering using the previously saved first char (no retro grab needed).
BeginBufferFromPending,
}
pub(crate) struct RetroGrab {
pub start_byte: usize,
pub grabbed: String,
}
pub(crate) enum FlushResult {
Paste(String),
Typed(char),
None,
}
impl PasteBurst {
/// Recommended delay to wait between simulated keypresses (or before
/// scheduling a UI tick) so that a pending fast keystroke is flushed
/// out of the burst detector as normal typed input.
///
/// Primarily used by tests and by the TUI to reliably cross the
/// paste-burst timing threshold.
pub fn recommended_flush_delay() -> Duration {
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
}
#[cfg(test)]
pub(crate) fn recommended_active_flush_delay() -> Duration {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
}
/// Entry point: decide how to treat a plain char with current timing.
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return CharDecision::BufferAppend;
}
// If we already held a first char and receive a second fast char,
// start buffering without retro-grabbing (we never rendered the first).
if let Some((held, held_at)) = self.pending_first_char
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
{
self.active = true;
// take() to clear pending; we already captured the held char above
let _ = self.pending_first_char.take();
self.buffer.push(held);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return CharDecision::BeginBufferFromPending;
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
};
}
// Save the first fast char very briefly to see if a burst follows.
self.pending_first_char = Some((ch, now));
CharDecision::RetainFirstChar
}
/// Like on_plain_char(), but never holds the first char.
///
/// Used for non-ASCII input paths (e.g., IMEs) where holding a character can
/// feel like dropped input, while still allowing burst-based paste detection.
///
/// Note: This method will only ever return BufferAppend or BeginBuffer.
pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return Some(CharDecision::BufferAppend);
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return Some(CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
});
}
None
}
fn note_plain_char(&mut self, now: Instant) {
match self.last_plain_char_time {
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
self.consecutive_plain_char_burst =
self.consecutive_plain_char_burst.saturating_add(1)
}
_ => self.consecutive_plain_char_burst = 1,
}
self.last_plain_char_time = Some(now);
}
/// Flushes any buffered burst if the inter-key timeout has elapsed.
///
/// Returns:
///
/// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one
/// pasted string.
/// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker
/// suppression) and no burst followed before the timeout elapsed.
/// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timeout = if self.is_active_internal() {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
} else {
PASTE_BURST_CHAR_INTERVAL
};
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > timeout);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);
FlushResult::Paste(out)
} else if timed_out {
// If we were saving a single fast char and no burst followed,
// flush it as normal typed input.
if let Some((ch, _at)) = self.pending_first_char.take() {
FlushResult::Typed(ch)
} else {
FlushResult::None
}
} else {
FlushResult::None
}
}
/// While bursting: accumulate a newline into the buffer instead of
/// submitting the textarea.
///
/// Returns true if a newline was appended (we are in a burst context),
/// false otherwise.
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
if self.is_active() {
self.buffer.push('\n');
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
true
} else {
false
}
}
/// Decide if Enter should insert a newline (burst context) vs submit.
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
self.is_active() || in_burst_window
}
/// Keep the burst window alive.
pub fn extend_window(&mut self, now: Instant) {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
/// Begin buffering with retroactively grabbed text.
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
if !grabbed.is_empty() {
self.buffer.push_str(&grabbed);
}
self.active = true;
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
/// Append a char into the burst buffer.
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
self.buffer.push(ch);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
}
/// Try to append a char into the burst buffer only if a burst is already active.
///
/// Returns true when the char was captured into the existing burst, false otherwise.
pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
if self.active || !self.buffer.is_empty() {
self.append_char_to_buffer(ch, now);
true
} else {
false
}
}
/// Decide whether to begin buffering by retroactively capturing recent
/// chars from the slice before the cursor.
///
/// Heuristic: if the retro-grabbed slice contains any whitespace or is
/// sufficiently long (>= 16 characters), treat it as paste-like to avoid
/// rendering the typed prefix momentarily before the paste is recognized.
/// This favors responsiveness and prevents flicker for typical pastes
/// (URLs, file paths, multiline text) while not triggering on short words.
///
/// Returns Some(RetroGrab) with the start byte and grabbed text when we
/// decide to buffer retroactively; otherwise None.
pub fn decide_begin_buffer(
&mut self,
now: Instant,
before: &str,
retro_chars: usize,
) -> Option<RetroGrab> {
let start_byte = retro_start_index(before, retro_chars);
let grabbed = before[start_byte..].to_string();
let looks_pastey =
grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
if looks_pastey {
// Note: caller is responsible for removing this slice from UI text.
self.begin_with_retro_grabbed(grabbed.clone(), now);
Some(RetroGrab {
start_byte,
grabbed,
})
} else {
None
}
}
/// Before applying modified/non-char input: flush buffered burst immediately.
pub fn flush_before_modified_input(&mut self) -> Option<String> {
if !self.is_active() {
return None;
}
self.active = false;
let mut out = std::mem::take(&mut self.buffer);
if let Some((ch, _at)) = self.pending_first_char.take() {
out.push(ch);
}
Some(out)
}
/// Clear only the timing window and any pending first-char.
///
/// Does not emit or clear the buffered text itself; callers should have
/// already flushed (if needed) via one of the flush methods above.
pub fn clear_window_after_non_char(&mut self) {
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.burst_window_until = None;
self.active = false;
self.pending_first_char = None;
}
/// Returns true if we are in any paste-burst related transient state
/// (actively buffering, have a non-empty buffer, or have saved the first
/// fast char while waiting for a potential burst).
pub fn is_active(&self) -> bool {
self.is_active_internal() || self.pending_first_char.is_some()
}
fn is_active_internal(&self) -> bool {
self.active || !self.buffer.is_empty()
}
pub fn clear_after_explicit_paste(&mut self) {
self.last_plain_char_time = None;
self.consecutive_plain_char_burst = 0;
self.burst_window_until = None;
self.active = false;
self.buffer.clear();
self.pending_first_char = None;
}
}
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
if retro_chars == 0 {
return before.len();
}
before
.char_indices()
.rev()
.nth(retro_chars.saturating_sub(1))
.map(|(idx, _)| idx)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
/// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows,
/// that held char should eventually flush as normal typed input (not as a paste).
#[test]
fn ascii_first_char_is_held_then_flushes_as_typed() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
assert!(!burst.is_active());
}
/// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever
/// rendering the first one, then flush the whole buffered payload as a paste.
#[test]
fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + Duration::from_millis(1);
assert!(matches!(
burst.on_plain_char('b', t1),
CharDecision::BeginBufferFromPending
));
burst.append_char_to_buffer('b', t1);
let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
assert!(matches!(
burst.flush_if_due(t2),
FlushResult::Paste(ref s) if s == "ab"
));
}
/// Behavior: when non-char input is about to be applied, we flush any transient burst state
/// immediately (including a single pending ASCII char) so state doesn't leak across inputs.
#[test]
fn flush_before_modified_input_includes_pending_first_char() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
assert!(!burst.is_active());
}
/// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks
/// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified.
#[test]
fn decide_begin_buffer_only_triggers_for_pastey_prefixes() {
let mut burst = PasteBurst::default();
let now = Instant::now();
assert!(burst.decide_begin_buffer(now, "ab", 2).is_none());
assert!(!burst.is_active());
let grab = burst
.decide_begin_buffer(now, "a b", 2)
.expect("whitespace should be considered paste-like");
assert_eq!(grab.start_byte, 1);
assert_eq!(grab.grabbed, " b");
assert!(burst.is_active());
}
/// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so
/// a slightly-late Enter still inserts a newline instead of submitting.
#[test]
fn newline_suppression_window_outlives_buffer_flush() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
assert!(matches!(
burst.on_plain_char('a', t0),
CharDecision::RetainFirstChar
));
let t1 = t0 + Duration::from_millis(1);
assert!(matches!(
burst.on_plain_char('b', t1),
CharDecision::BeginBufferFromPending
));
burst.append_char_to_buffer('b', t1);
let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab"));
assert!(!burst.is_active());
assert!(burst.newline_should_insert_instead_of_submit(t2));
let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
assert!(!burst.newline_should_insert_instead_of_submit(t3));
}
}
@@ -0,0 +1,320 @@
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use crate::key_hint;
use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that displays pending steers plus user messages queued while a turn is in progress.
///
/// The widget renders pending steers first, then queued user messages, as two
/// labeled sections. Pending steers explain that they will be submitted after
/// the next tool/result boundary unless the user presses Esc to interrupt and
/// send them immediately. The edit hint at the bottom only appears when there
/// are actual queued user messages to pop back into the composer. Because some
/// terminals intercept certain modifier-key combinations, the displayed
/// binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct PendingInputPreview {
pub pending_steers: Vec<String>,
pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable.
edit_binding: key_hint::KeyBinding,
}
const PREVIEW_LINE_LIMIT: usize = 3;
impl PendingInputPreview {
pub(crate) fn new() -> Self {
Self {
pending_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: key_hint::alt(KeyCode::Up),
}
}
/// Replace the keybinding shown in the hint line at the bottom of the
/// queued-messages list. The caller is responsible for also wiring the
/// corresponding key event handler.
pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) {
self.edit_binding = binding;
}
fn push_truncated_preview_lines(
lines: &mut Vec<Line<'static>>,
wrapped: Vec<Line<'static>>,
overflow_line: Line<'static>,
) {
let wrapped_len = wrapped.len();
lines.extend(wrapped.into_iter().take(PREVIEW_LINE_LIMIT));
if wrapped_len > PREVIEW_LINE_LIMIT {
lines.push(overflow_line);
}
}
fn push_section_header(lines: &mut Vec<Line<'static>>, width: u16, header: Line<'static>) {
let mut spans = vec!["• ".dim()];
spans.extend(header.spans);
lines.extend(adaptive_wrap_lines(
std::iter::once(Line::from(spans)),
RtOptions::new(width as usize).subsequent_indent(Line::from(" ".dim())),
));
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if (self.pending_steers.is_empty() && self.queued_messages.is_empty()) || width < 4 {
return Box::new(());
}
let mut lines = vec![];
if !self.pending_steers.is_empty() {
Self::push_section_header(
&mut lines,
width,
Line::from(vec![
"Messages to be submitted after next tool call".into(),
" (press ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" to interrupt and send immediately)".dim(),
]),
);
for steer in &self.pending_steers {
let wrapped = adaptive_wrap_lines(
steer.lines().map(|line| Line::from(line.dim())),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ↳ ".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(&mut lines, wrapped, Line::from(" …".dim()));
}
}
if !self.queued_messages.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
Self::push_section_header(&mut lines, width, "Queued follow-up messages".into());
for message in &self.queued_messages {
let wrapped = adaptive_wrap_lines(
message.lines().map(|line| Line::from(line.dim().italic())),
RtOptions::new(width as usize)
.initial_indent(Line::from(" ↳ ".dim()))
.subsequent_indent(Line::from(" ")),
);
Self::push_truncated_preview_lines(
&mut lines,
wrapped,
Line::from(" …".dim().italic()),
);
}
}
if !self.queued_messages.is_empty() {
lines.push(
Line::from(vec![
" ".into(),
self.edit_binding.into(),
" edit last queued message".into(),
])
.dim(),
);
}
Paragraph::new(lines).into()
}
}
impl Renderable for PendingInputPreview {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
self.as_renderable(area.width).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable(width).desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
#[test]
fn desired_height_empty() {
let queue = PendingInputPreview::new();
assert_eq!(queue.desired_height(40), 0);
}
#[test]
fn desired_height_one_message() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
assert_eq!(queue.desired_height(40), 3);
}
#[test]
fn render_one_message() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_one_message", format!("{buf:?}"));
}
#[test]
fn render_two_messages() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue
.queued_messages
.push("This is another message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_two_messages", format!("{buf:?}"));
}
#[test]
fn render_more_than_three_messages() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue
.queued_messages
.push("This is another message".to_string());
queue
.queued_messages
.push("This is a third message".to_string());
queue
.queued_messages
.push("This is a fourth message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_more_than_three_messages", format!("{buf:?}"));
}
#[test]
fn render_wrapped_message() {
let mut queue = PendingInputPreview::new();
queue
.queued_messages
.push("This is a longer message that should be wrapped".to_string());
queue
.queued_messages
.push("This is another message".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_wrapped_message", format!("{buf:?}"));
}
#[test]
fn render_many_line_message() {
let mut queue = PendingInputPreview::new();
queue
.queued_messages
.push("This is\na message\nwith many\nlines".to_string());
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_many_line_message", format!("{buf:?}"));
}
#[test]
fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push(
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789"
.to_string(),
);
let width = 36;
let height = queue.desired_height(width);
assert_eq!(
height, 3,
"expected header, one message row, and hint row for URL-like token"
);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
let rendered_rows = (0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(
!rendered_rows.iter().any(|row| row.contains('…')),
"expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}"
);
}
#[test]
fn render_one_pending_steer() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!("render_one_pending_steer", format!("{buf:?}"));
}
#[test]
fn render_pending_steers_above_queued_messages() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
queue
.pending_steers
.push("Check the last command output.".to_string());
queue
.queued_messages
.push("Queued follow-up question".to_string());
let width = 52;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!(
"render_pending_steers_above_queued_messages",
format!("{buf:?}")
);
}
#[test]
fn render_multiline_pending_steer_uses_single_prefix_and_truncates() {
let mut queue = PendingInputPreview::new();
queue
.pending_steers
.push("First line\nSecond line\nThird line\nFourth line".to_string());
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
queue.render(Rect::new(0, 0, width, height), &mut buf);
assert_snapshot!(
"render_multiline_pending_steer_uses_single_prefix_and_truncates",
format!("{buf:?}")
);
}
}
@@ -0,0 +1,149 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that lists inactive threads with outstanding approval requests.
pub(crate) struct PendingThreadApprovals {
threads: Vec<String>,
}
impl PendingThreadApprovals {
pub(crate) fn new() -> Self {
Self {
threads: Vec::new(),
}
}
pub(crate) fn set_threads(&mut self, threads: Vec<String>) -> bool {
if self.threads == threads {
return false;
}
self.threads = threads;
true
}
pub(crate) fn is_empty(&self) -> bool {
self.threads.is_empty()
}
#[cfg(test)]
pub(crate) fn threads(&self) -> &[String] {
&self.threads
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if self.threads.is_empty() || width < 4 {
return Box::new(());
}
let mut lines = Vec::new();
for thread in self.threads.iter().take(3) {
let wrapped = adaptive_wrap_lines(
std::iter::once(Line::from(format!("Approval needed in {thread}"))),
RtOptions::new(width as usize)
.initial_indent(Line::from(vec![" ".into(), "!".red().bold(), " ".into()]))
.subsequent_indent(Line::from(" ")),
);
lines.extend(wrapped);
}
if self.threads.len() > 3 {
lines.push(Line::from(" ...".dim().italic()));
}
lines.push(
Line::from(vec![
" ".into(),
"/agent".cyan().bold(),
" to switch threads".dim(),
])
.dim(),
);
Paragraph::new(lines).into()
}
}
impl Renderable for PendingThreadApprovals {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
self.as_renderable(area.width).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable(width).desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
fn snapshot_rows(widget: &PendingThreadApprovals, width: u16) -> String {
let height = widget.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
widget.render(Rect::new(0, 0, width, height), &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn desired_height_empty() {
let widget = PendingThreadApprovals::new();
assert_eq!(widget.desired_height(40), 0);
}
#[test]
fn render_single_thread_snapshot() {
let mut widget = PendingThreadApprovals::new();
widget.set_threads(vec!["Robie [explorer]".to_string()]);
assert_snapshot!(
snapshot_rows(&widget, 40).replace(' ', "."),
@r"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............
"
);
}
#[test]
fn render_multiple_threads_snapshot() {
let mut widget = PendingThreadApprovals::new();
widget.set_threads(vec![
"Main [default]".to_string(),
"Robie [explorer]".to_string(),
"Inspector".to_string(),
"Extra agent".to_string(),
]);
assert_snapshot!(
snapshot_rows(&widget, 44).replace(' ', "."),
@r"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................
"
);
}
}
@@ -0,0 +1,21 @@
//! Shared popup-related constants for bottom pane widgets.
use crossterm::event::KeyCode;
use ratatui::text::Line;
use crate::key_hint;
/// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;
/// Standard footer hint text used by popups.
pub(crate) fn standard_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to confirm or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
}
@@ -0,0 +1,854 @@
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use lazy_static::lazy_static;
use regex_lite::Regex;
use shlex::Shlex;
use std::collections::HashMap;
use std::collections::HashSet;
lazy_static! {
static ref PROMPT_ARG_REGEX: Regex =
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
}
#[derive(Debug)]
pub enum PromptArgsError {
MissingAssignment { token: String },
MissingKey { token: String },
}
impl PromptArgsError {
fn describe(&self, command: &str) -> String {
match self {
PromptArgsError::MissingAssignment { token } => format!(
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
),
PromptArgsError::MissingKey { token } => {
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
}
}
}
}
#[derive(Debug)]
pub enum PromptExpansionError {
Args {
command: String,
error: PromptArgsError,
},
MissingArgs {
command: String,
missing: Vec<String>,
},
}
impl PromptExpansionError {
pub fn user_message(&self) -> String {
match self {
PromptExpansionError::Args { command, error } => error.describe(command),
PromptExpansionError::MissingArgs { command, missing } => {
let list = missing.join(", ");
format!(
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
)
}
}
}
}
/// Parse a first-line slash command of the form `/name <rest>`.
/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/`
/// and contains a non-empty name; otherwise returns `None`.
///
/// `rest_offset` is the byte index into the original line where `rest_after_name`
/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`).
pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> {
let stripped = line.strip_prefix('/')?;
let mut name_end_in_stripped = stripped.len();
for (idx, ch) in stripped.char_indices() {
if ch.is_whitespace() {
name_end_in_stripped = idx;
break;
}
}
let name = &stripped[..name_end_in_stripped];
if name.is_empty() {
return None;
}
let rest_untrimmed = &stripped[name_end_in_stripped..];
let rest = rest_untrimmed.trim_start();
let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len());
// `stripped` is `line` without the leading '/', so add 1 to get the original offset.
let rest_offset = rest_start_in_stripped + 1;
Some((name, rest, rest_offset))
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptArg {
pub text: String,
pub text_elements: Vec<TextElement>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromptExpansion {
pub text: String,
pub text_elements: Vec<TextElement>,
}
/// Parse positional arguments using shlex semantics (supports quoted tokens).
///
/// `text_elements` must be relative to `rest`.
pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
parse_tokens_with_elements(rest, text_elements)
}
/// Extracts the unique placeholder variable names from a prompt template.
///
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
/// (for example `$USER`). The function returns the variable names without
/// the leading `$`, de-duplicated and in the order of first appearance.
pub fn prompt_argument_names(content: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = Vec::new();
for m in PROMPT_ARG_REGEX.find_iter(content) {
if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' {
continue;
}
let name = &content[m.start() + 1..m.end()];
// Exclude special positional aggregate token from named args.
if name == "ARGUMENTS" {
continue;
}
let name = name.to_string();
if seen.insert(name.clone()) {
names.push(name);
}
}
names
}
/// Shift a text element's byte range left by `offset`, returning `None` if empty.
///
/// `offset` is the byte length of the prefix removed from the original text.
fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option<TextElement> {
if elem.byte_range.end <= offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(offset);
let end = elem.byte_range.end.saturating_sub(offset);
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
}
/// Parses the `key=value` pairs that follow a custom prompt name.
///
/// The input is split using shlex rules, so quoted values are supported
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
/// arguments, or an error if a token is missing `=` or if the key is empty.
pub fn parse_prompt_inputs(
rest: &str,
text_elements: &[TextElement],
) -> Result<HashMap<String, PromptArg>, PromptArgsError> {
let mut map = HashMap::new();
if rest.trim().is_empty() {
return Ok(map);
}
// Tokenize the rest of the command using shlex rules, but keep text element
// ranges relative to each emitted token.
for token in parse_tokens_with_elements(rest, text_elements) {
let Some((key, value)) = token.text.split_once('=') else {
return Err(PromptArgsError::MissingAssignment { token: token.text });
};
if key.is_empty() {
return Err(PromptArgsError::MissingKey { token: token.text });
}
// The token is `key=value`; translate element ranges into the value-only
// coordinate space by subtracting the `key=` prefix length.
let value_start = key.len() + 1;
let value_elements = token
.text_elements
.iter()
.filter_map(|elem| shift_text_element_left(elem, value_start))
.collect();
map.insert(
key.to_string(),
PromptArg {
text: value.to_string(),
text_elements: value_elements,
},
);
}
Ok(map)
}
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
///
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
/// the function returns `Ok(None)`. On success it returns
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
pub fn expand_custom_prompt(
text: &str,
text_elements: &[TextElement],
custom_prompts: &[CustomPrompt],
) -> Result<Option<PromptExpansion>, PromptExpansionError> {
let Some((name, rest, rest_offset)) = parse_slash_name(text) else {
return Ok(None);
};
// Only handle custom prompts when using the explicit prompts prefix with a colon.
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Ok(None);
};
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
Some(prompt) => prompt,
None => return Ok(None),
};
// If there are named placeholders, expect key=value inputs.
let required = prompt_argument_names(&prompt.content);
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, rest_offset)?;
if shifted.byte_range.start >= rest.len() {
return None;
}
let end = shifted.byte_range.end.min(rest.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
if !required.is_empty() {
let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| {
PromptExpansionError::Args {
command: format!("/{name}"),
error,
}
})?;
let missing: Vec<String> = required
.into_iter()
.filter(|k| !inputs.contains_key(k))
.collect();
if !missing.is_empty() {
return Err(PromptExpansionError::MissingArgs {
command: format!("/{name}"),
missing,
});
}
let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs);
return Ok(Some(PromptExpansion {
text,
text_elements: elements,
}));
}
// Otherwise, treat it as numeric/positional placeholder prompt (or none).
let pos_args = parse_positional_args(rest, &local_elements);
Ok(Some(expand_numeric_placeholders(
&prompt.content,
&pos_args,
)))
}
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
pub fn prompt_has_numeric_placeholders(content: &str) -> bool {
if content.contains("$ARGUMENTS") {
return true;
}
let bytes = content.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'$' {
let b1 = bytes[i + 1];
if (b'1'..=b'9').contains(&b1) {
return true;
}
}
i += 1;
}
false
}
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
/// Returns empty when the command name does not match or when there are no args.
pub fn extract_positional_args_for_prompt_line(
line: &str,
prompt_name: &str,
text_elements: &[TextElement],
) -> Vec<PromptArg> {
let trimmed = line.trim_start();
let trim_offset = line.len() - trimmed.len();
let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else {
return Vec::new();
};
// Require the explicit prompts prefix for custom prompt invocations.
let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Vec::new();
};
if after_prefix != prompt_name {
return Vec::new();
}
let rest_trimmed_start = rest.trim_start();
let args_str = rest_trimmed_start.trim_end();
if args_str.is_empty() {
return Vec::new();
}
let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len());
let local_elements: Vec<TextElement> = text_elements
.iter()
.filter_map(|elem| {
let mut shifted = shift_text_element_left(elem, args_offset)?;
if shifted.byte_range.start >= args_str.len() {
return None;
}
let end = shifted.byte_range.end.min(args_str.len());
shifted.byte_range.end = end;
(shifted.byte_range.start < shifted.byte_range.end).then_some(shifted)
})
.collect();
parse_positional_args(args_str, &local_elements)
}
/// If the prompt only uses numeric placeholders and the first line contains
/// positional args for it, expand and return Some(expanded); otherwise None.
pub fn expand_if_numeric_with_positional_args(
prompt: &CustomPrompt,
first_line: &str,
text_elements: &[TextElement],
) -> Option<PromptExpansion> {
if !prompt_argument_names(&prompt.content).is_empty() {
return None;
}
if !prompt_has_numeric_placeholders(&prompt.content) {
return None;
}
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements);
if args.is_empty() {
return None;
}
Some(expand_numeric_placeholders(&prompt.content, &args))
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut i = 0;
while let Some(off) = content[i..].find('$') {
let j = i + off;
out.push_str(&content[i..j]);
let rest = &content[j..];
let bytes = rest.as_bytes();
if bytes.len() >= 2 {
match bytes[1] {
b'$' => {
out.push_str("$$");
i = j + 2;
continue;
}
b'1'..=b'9' => {
let idx = (bytes[1] - b'1') as usize;
if let Some(arg) = args.get(idx) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
}
i = j + 2;
continue;
}
_ => {}
}
}
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
append_joined_args_with_elements(&mut out, &mut out_elements, args);
}
i = j + 1 + "ARGUMENTS".len();
continue;
}
out.push('$');
i = j + 1;
}
out.push_str(&content[i..]);
PromptExpansion {
text: out,
text_elements: out_elements,
}
}
fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec<PromptArg> {
let mut elements = text_elements.to_vec();
elements.sort_by_key(|elem| elem.byte_range.start);
// Keep element placeholders intact across shlex splitting by replacing
// each element range with a unique sentinel token first.
let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements);
Shlex::new(&rest_for_shlex)
.map(|token| apply_replacements_to_token(token, &replacements))
.collect()
}
#[derive(Debug, Clone)]
struct ElementReplacement {
sentinel: String,
text: String,
placeholder: Option<String>,
}
/// Replace each text element range with a unique sentinel token.
///
/// The sentinel is chosen so it will survive shlex tokenization as a single word.
fn replace_text_elements_with_sentinels(
rest: &str,
elements: &[TextElement],
) -> (String, Vec<ElementReplacement>) {
let mut out = String::with_capacity(rest.len());
let mut replacements = Vec::new();
let mut cursor = 0;
for (idx, elem) in elements.iter().enumerate() {
let start = elem.byte_range.start;
let end = elem.byte_range.end;
out.push_str(&rest[cursor..start]);
let mut sentinel = format!("__CODEX_ELEM_{idx}__");
// Ensure we never collide with user content so a sentinel can't be mistaken for text.
while rest.contains(&sentinel) {
sentinel.push('_');
}
out.push_str(&sentinel);
replacements.push(ElementReplacement {
sentinel,
text: rest[start..end].to_string(),
placeholder: elem.placeholder(rest).map(str::to_string),
});
cursor = end;
}
out.push_str(&rest[cursor..]);
(out, replacements)
}
/// Rehydrate a shlex token by swapping sentinels back to the original text
/// and rebuilding text element ranges relative to the resulting token.
fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg {
if replacements.is_empty() {
return PromptArg {
text: token,
text_elements: Vec::new(),
};
}
let mut out = String::with_capacity(token.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
while cursor < token.len() {
let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else {
out.push_str(&token[cursor..]);
break;
};
let start_in_token = cursor + offset;
out.push_str(&token[cursor..start_in_token]);
let start = out.len();
out.push_str(&replacement.text);
let end = out.len();
if start < end {
out_elements.push(TextElement::new(
ByteRange { start, end },
replacement.placeholder.clone(),
));
}
cursor = start_in_token + replacement.sentinel.len();
}
PromptArg {
text: out,
text_elements: out_elements,
}
}
/// Find the earliest sentinel occurrence at or after `cursor`.
fn next_replacement<'a>(
token: &str,
cursor: usize,
replacements: &'a [ElementReplacement],
) -> Option<(usize, &'a ElementReplacement)> {
let slice = &token[cursor..];
let mut best: Option<(usize, &'a ElementReplacement)> = None;
for replacement in replacements {
if let Some(pos) = slice.find(&replacement.sentinel) {
match best {
Some((best_pos, _)) if best_pos <= pos => {}
_ => best = Some((pos, replacement)),
}
}
}
best
}
fn expand_named_placeholders_with_elements(
content: &str,
args: &HashMap<String, PromptArg>,
) -> (String, Vec<TextElement>) {
let mut out = String::with_capacity(content.len());
let mut out_elements = Vec::new();
let mut cursor = 0;
for m in PROMPT_ARG_REGEX.find_iter(content) {
let start = m.start();
let end = m.end();
if start > 0 && content.as_bytes()[start - 1] == b'$' {
out.push_str(&content[cursor..end]);
cursor = end;
continue;
}
out.push_str(&content[cursor..start]);
cursor = end;
let key = &content[start + 1..end];
if let Some(arg) = args.get(key) {
append_arg_with_elements(&mut out, &mut out_elements, arg);
} else {
out.push_str(&content[start..end]);
}
}
out.push_str(&content[cursor..]);
(out, out_elements)
}
fn append_arg_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
arg: &PromptArg,
) {
let start = out.len();
out.push_str(&arg.text);
if arg.text_elements.is_empty() {
return;
}
out_elements.extend(arg.text_elements.iter().map(|elem| {
elem.map_range(|range| ByteRange {
start: start + range.start,
end: start + range.end,
})
}));
}
fn append_joined_args_with_elements(
out: &mut String,
out_elements: &mut Vec<TextElement>,
args: &[PromptArg],
) {
// `$ARGUMENTS` joins args with single spaces while preserving element ranges.
for (idx, arg) in args.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
append_arg_with_elements(out, out_elements, arg);
}
}
/// Constructs a command text for a custom prompt with arguments.
/// Returns the text and the cursor position (inside the first double quote).
pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) {
let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}");
let mut cursor: usize = text.len();
for (i, arg) in args.iter().enumerate() {
text.push_str(format!(" {arg}=\"\"").as_str());
if i == 0 {
cursor = text.len() - 1; // inside first ""
}
}
(text, cursor)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn expand_arguments_basic() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Review Alice changes on main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn quoted_values_ok() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Pair $USER with $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt(
"/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main",
&[],
&prompts,
)
.unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "Pair Alice Smith with dev-main".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn invalid_arg_token_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.contains("expected key=value"));
}
#[test]
fn missing_required_args_reports_error() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "Review $USER changes on $BRANCH".to_string(),
description: None,
argument_hint: None,
}];
let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts)
.unwrap_err()
.user_message();
assert!(err.to_lowercase().contains("missing required args"));
assert!(err.contains("BRANCH"));
}
#[test]
fn escaped_placeholder_is_ignored() {
assert_eq!(
prompt_argument_names("literal $$USER"),
Vec::<String>::new()
);
assert_eq!(
prompt_argument_names("literal $$USER and $REAL"),
vec!["REAL".to_string()]
);
}
#[test]
fn escaped_placeholder_remains_literal() {
let prompts = vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: "literal $$USER".to_string(),
description: None,
argument_hint: None,
}];
let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap();
assert_eq!(
out,
Some(PromptExpansion {
text: "literal $$USER".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("alpha {placeholder} beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn extract_positional_args_shifts_element_offsets_into_args_str() {
let placeholder = "[Image #1]";
let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta ");
let start = line.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_treat_placeholder_with_spaces_as_single_token() {
let placeholder = "[Image #1]";
let rest = format!("IMG={placeholder} NOTE=hello");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: placeholder.to_string(),
text_elements: vec![TextElement::new(
ByteRange {
start: 0,
end: placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "hello".to_string(),
text_elements: Vec::new(),
})
);
}
#[test]
fn positional_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("alpha \"see {placeholder} here\" beta");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_positional_args(&rest, &text_elements);
assert_eq!(
args,
vec![
PromptArg {
text: "alpha".to_string(),
text_elements: Vec::new(),
},
PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
},
PromptArg {
text: "beta".to_string(),
text_elements: Vec::new(),
}
]
);
}
#[test]
fn key_value_args_allow_placeholder_inside_quotes() {
let placeholder = "[Image #1]";
let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok");
let start = rest.find(placeholder).expect("placeholder");
let end = start + placeholder.len();
let text_elements = vec![TextElement::new(
ByteRange { start, end },
Some(placeholder.to_string()),
)];
let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs");
assert_eq!(
args.get("IMG"),
Some(&PromptArg {
text: format!("see {placeholder} here"),
text_elements: vec![TextElement::new(
ByteRange {
start: "see ".len(),
end: "see ".len() + placeholder.len(),
},
Some(placeholder.to_string()),
)],
})
);
assert_eq!(
args.get("NOTE"),
Some(&PromptArg {
text: "ok".to_string(),
text_elements: Vec::new(),
})
);
}
}
@@ -0,0 +1,363 @@
use ratatui::layout::Rect;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
use super::RequestUserInputOverlay;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) question_area: Rect,
// Wrapped question text lines to render in the question area.
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
pub(super) notes_area: Rect,
// Number of footer rows (status + hints).
pub(super) footer_lines: u16,
}
impl RequestUserInputOverlay {
/// Compute layout sections, collapsing notes and hints as space shrinks.
pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections {
let has_options = self.has_options();
let notes_visible = !has_options || self.notes_ui_visible();
let footer_pref = self.footer_required_height(area.width);
let notes_pref_height = self.notes_input_height(area.width);
let mut question_lines = self.wrapped_question_lines(area.width);
let question_height = question_lines.len() as u16;
let layout = if has_options {
self.layout_with_options(
OptionsLayoutArgs {
available_height: area.height,
width: area.width,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
},
&mut question_lines,
)
} else {
self.layout_without_options(
area.height,
question_height,
notes_pref_height,
footer_pref,
&mut question_lines,
)
};
let (progress_area, question_area, options_area, notes_area) =
self.build_layout_areas(area, layout);
LayoutSections {
progress_area,
question_area,
question_lines,
options_area,
notes_area,
footer_lines: layout.footer_lines,
}
}
/// Layout calculation when options are present.
fn layout_with_options(
&self,
args: OptionsLayoutArgs,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let OptionsLayoutArgs {
available_height,
width,
mut question_height,
notes_pref_height,
footer_pref,
notes_visible,
} = args;
let min_options_height = available_height.min(1);
let max_question_height = available_height.saturating_sub(min_options_height);
if question_height > max_question_height {
question_height = max_question_height;
question_lines.truncate(question_height as usize);
}
self.layout_with_options_normal(
OptionsNormalArgs {
available_height,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
},
OptionsHeights {
preferred: self.options_preferred_height(width),
full: self.options_required_height(width),
},
)
}
/// Normal layout for options case: allocate footer + progress first, and
/// only allocate notes (and its label) when explicitly visible.
fn layout_with_options_normal(
&self,
args: OptionsNormalArgs,
options: OptionsHeights,
) -> LayoutPlan {
let OptionsNormalArgs {
available_height,
question_height,
notes_pref_height,
footer_pref,
notes_visible,
} = args;
let max_options_height = available_height.saturating_sub(question_height);
let min_options_height = max_options_height.min(1);
let mut options_height = options
.preferred
.min(max_options_height)
.max(min_options_height);
let used = question_height.saturating_add(options_height);
let mut remaining = available_height.saturating_sub(used);
// When notes are hidden, prefer to reserve room for progress, footer,
// and spacers by shrinking the options window if needed.
let desired_spacers = if notes_visible {
// Notes already separate options from the footer, so only keep a
// single spacer between the question and options.
1
} else {
DESIRED_SPACERS_BETWEEN_SECTIONS
};
let required_extra = footer_pref
.saturating_add(1) // progress line
.saturating_add(desired_spacers);
if remaining < required_extra {
let deficit = required_extra.saturating_sub(remaining);
let reducible = options_height.saturating_sub(min_options_height);
let reduce_by = deficit.min(reducible);
options_height = options_height.saturating_sub(reduce_by);
remaining = remaining.saturating_add(reduce_by);
}
let mut progress_height = 0;
if remaining > 0 {
progress_height = 1;
remaining = remaining.saturating_sub(1);
}
if !notes_visible {
let mut spacer_after_options = 0;
if remaining > footer_pref {
spacer_after_options = 1;
remaining = remaining.saturating_sub(1);
}
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let grow_by = remaining.min(options.full.saturating_sub(options_height));
options_height = options_height.saturating_add(grow_by);
return LayoutPlan {
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_height: 0,
footer_lines,
};
}
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
// Prefer spacers before notes, then notes.
let mut spacer_after_question = 0;
if remaining > 0 {
spacer_after_question = 1;
remaining = remaining.saturating_sub(1);
}
let spacer_after_options = 0;
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
notes_height = notes_height.saturating_add(remaining);
LayoutPlan {
question_height,
progress_height,
spacer_after_question,
options_height,
spacer_after_options,
notes_height,
footer_lines,
}
}
/// Layout calculation when no options are present.
///
/// Handles both tight layout (when space is constrained) and normal layout
/// (when there's sufficient space for all elements).
///
fn layout_without_options(
&self,
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let required = question_height;
if required > available_height {
self.layout_without_options_tight(available_height, question_height, question_lines)
} else {
self.layout_without_options_normal(
available_height,
question_height,
notes_pref_height,
footer_pref,
)
}
}
/// Tight layout for no-options case: truncate question to fit available space.
fn layout_without_options_tight(
&self,
available_height: u16,
question_height: u16,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let max_question_height = available_height;
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
LayoutPlan {
question_height: adjusted_question_height,
progress_height: 0,
spacer_after_question: 0,
options_height: 0,
spacer_after_options: 0,
notes_height: 0,
footer_lines: 0,
}
}
/// Normal layout for no-options case: allocate space for notes, footer, and progress.
fn layout_without_options_normal(
&self,
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> LayoutPlan {
let required = question_height;
let mut remaining = available_height.saturating_sub(required);
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
let footer_lines = footer_pref.min(remaining);
remaining = remaining.saturating_sub(footer_lines);
let mut progress_height = 0;
if remaining > 0 {
progress_height = 1;
remaining = remaining.saturating_sub(1);
}
notes_height = notes_height.saturating_add(remaining);
LayoutPlan {
question_height,
progress_height,
spacer_after_question: 0,
options_height: 0,
spacer_after_options: 0,
notes_height,
footer_lines,
}
}
/// Build the final layout areas from computed heights.
fn build_layout_areas(
&self,
area: Rect,
heights: LayoutPlan,
) -> (
Rect, // progress_area
Rect, // question_area
Rect, // options_area
Rect, // notes_area
) {
let mut cursor_y = area.y;
let progress_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.progress_height,
};
cursor_y = cursor_y.saturating_add(heights.progress_height);
let question_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.question_height,
};
cursor_y = cursor_y.saturating_add(heights.question_height);
cursor_y = cursor_y.saturating_add(heights.spacer_after_question);
let options_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.options_height,
};
cursor_y = cursor_y.saturating_add(heights.options_height);
cursor_y = cursor_y.saturating_add(heights.spacer_after_options);
let notes_area = Rect {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.notes_height,
};
(progress_area, question_area, options_area, notes_area)
}
}
#[derive(Clone, Copy, Debug)]
struct LayoutPlan {
progress_height: u16,
question_height: u16,
spacer_after_question: u16,
options_height: u16,
spacer_after_options: u16,
notes_height: u16,
footer_lines: u16,
}
#[derive(Clone, Copy, Debug)]
struct OptionsLayoutArgs {
available_height: u16,
width: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,
}
#[derive(Clone, Copy, Debug)]
struct OptionsNormalArgs {
available_height: u16,
question_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,
}
#[derive(Clone, Copy, Debug)]
struct OptionsHeights {
preferred: u16,
full: u16,
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,582 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::bottom_pane::selection_popup_common::wrap_styled_line;
use crate::render::renderable::Renderable;
use super::DESIRED_SPACERS_BETWEEN_SECTIONS;
use super::RequestUserInputOverlay;
use super::TIP_SEPARATOR;
const MIN_OVERLAY_HEIGHT: usize = 8;
const PROGRESS_ROW_HEIGHT: usize = 1;
const SPACER_ROWS_WITH_NOTES: usize = 1;
const SPACER_ROWS_NO_OPTIONS: usize = 0;
struct UnansweredConfirmationData {
title_line: Line<'static>,
subtitle_line: Line<'static>,
hint_line: Line<'static>,
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
state: ScrollState,
}
struct UnansweredConfirmationLayout {
header_lines: Vec<Line<'static>>,
hint_lines: Vec<Line<'static>>,
rows: Vec<crate::bottom_pane::selection_popup_common::GenericDisplayRow>,
state: ScrollState,
}
fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
impl Renderable for RequestUserInputOverlay {
fn desired_height(&self, width: u16) -> u16 {
if self.confirm_unanswered_active() {
return self.unanswered_confirmation_height(width);
}
let outer = Rect::new(0, 0, width, u16::MAX);
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
let has_options = self.has_options();
let question_height = self.wrapped_question_lines(inner_width).len();
let options_height = if has_options {
self.options_preferred_height(inner_width) as usize
} else {
0
};
let notes_visible = !has_options || self.notes_ui_visible();
let notes_height = if notes_visible {
self.notes_input_height(inner_width) as usize
} else {
0
};
// When notes are visible, the composer already separates options from the footer.
// Without notes, we keep extra spacing so the footer hints don't crowd the options.
let spacer_rows = if has_options {
if notes_visible {
SPACER_ROWS_WITH_NOTES
} else {
DESIRED_SPACERS_BETWEEN_SECTIONS as usize
}
} else {
SPACER_ROWS_NO_OPTIONS
};
let footer_height = self.footer_required_height(inner_width) as usize;
// Tight minimum height: progress + question + (optional) titles/options
// + notes composer + footer + menu padding.
let mut height = question_height
.saturating_add(options_height)
.saturating_add(spacer_rows)
.saturating_add(notes_height)
.saturating_add(footer_height)
.saturating_add(PROGRESS_ROW_HEIGHT); // progress
height = height.saturating_add(menu_surface_padding_height() as usize);
height.max(MIN_OVERLAY_HEIGHT) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ui(area, buf);
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.cursor_pos_impl(area)
}
}
impl RequestUserInputOverlay {
fn unanswered_confirmation_data(&self) -> UnansweredConfirmationData {
let unanswered = self.unanswered_question_count();
let subtitle = format!(
"{unanswered} unanswered question{}",
if unanswered == 1 { "" } else { "s" }
);
UnansweredConfirmationData {
title_line: Line::from(super::UNANSWERED_CONFIRM_TITLE.bold()),
subtitle_line: Line::from(subtitle.dim()),
hint_line: standard_popup_hint_line(),
rows: self.unanswered_confirmation_rows(),
state: self.confirm_unanswered.unwrap_or_default(),
}
}
fn unanswered_confirmation_layout(&self, width: u16) -> UnansweredConfirmationLayout {
let data = self.unanswered_confirmation_data();
let content_width = width.max(1);
let mut header_lines = wrap_styled_line(&data.title_line, content_width);
let mut subtitle_lines = wrap_styled_line(&data.subtitle_line, content_width);
header_lines.append(&mut subtitle_lines);
let header_lines = header_lines.into_iter().map(line_to_owned).collect();
let hint_lines = wrap_styled_line(&data.hint_line, content_width)
.into_iter()
.map(line_to_owned)
.collect();
UnansweredConfirmationLayout {
header_lines,
hint_lines,
rows: data.rows,
state: data.state,
}
}
fn unanswered_confirmation_height(&self, width: u16) -> u16 {
let outer = Rect::new(0, 0, width, u16::MAX);
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
let layout = self.unanswered_confirmation_layout(inner_width);
let rows_height = measure_rows_height(
&layout.rows,
&layout.state,
layout.rows.len().max(1),
inner_width.max(1),
);
let height = layout.header_lines.len() as u16
+ 1
+ rows_height
+ 1
+ layout.hint_lines.len() as u16
+ menu_surface_padding_height();
height.max(MIN_OVERLAY_HEIGHT as u16)
}
fn render_unanswered_confirmation(&self, area: Rect, buf: &mut Buffer) {
let content_area = render_menu_surface(area, buf);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let width = content_area.width.max(1);
let layout = self.unanswered_confirmation_layout(width);
let mut cursor_y = content_area.y;
for line in layout.header_lines {
if cursor_y >= content_area.y + content_area.height {
return;
}
Paragraph::new(line).render(
Rect {
x: content_area.x,
y: cursor_y,
width: content_area.width,
height: 1,
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
}
if cursor_y < content_area.y + content_area.height {
cursor_y = cursor_y.saturating_add(1);
}
let remaining = content_area
.height
.saturating_sub(cursor_y.saturating_sub(content_area.y));
if remaining == 0 {
return;
}
let hint_height = layout.hint_lines.len() as u16;
let spacer_before_hint = u16::from(remaining > hint_height);
let rows_height = remaining.saturating_sub(hint_height + spacer_before_hint);
let rows_area = Rect {
x: content_area.x,
y: cursor_y,
width: content_area.width,
height: rows_height,
};
render_rows(
rows_area,
buf,
&layout.rows,
&layout.state,
layout.rows.len().max(1),
"No choices",
);
cursor_y = cursor_y.saturating_add(rows_height);
if spacer_before_hint > 0 {
cursor_y = cursor_y.saturating_add(1);
}
for (offset, line) in layout.hint_lines.into_iter().enumerate() {
let y = cursor_y.saturating_add(offset as u16);
if y >= content_area.y + content_area.height {
break;
}
Paragraph::new(line).render(
Rect {
x: content_area.x,
y,
width: content_area.width,
height: 1,
},
buf,
);
}
}
/// Render the full request-user-input overlay.
pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
if self.confirm_unanswered_active() {
self.render_unanswered_confirmation(area, buf);
return;
}
// Paint the same menu surface used by other bottom-pane overlays and
// then render the overlay content inside its inset area.
let content_area = render_menu_surface(area, buf);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let sections = self.layout_sections(content_area);
let notes_visible = self.notes_ui_visible();
let unanswered = self.unanswered_count();
// Progress header keeps the user oriented across multiple questions.
let progress_line = if self.question_count() > 0 {
let idx = self.current_index() + 1;
let total = self.question_count();
let base = format!("Question {idx}/{total}");
if unanswered > 0 {
Line::from(format!("{base} ({unanswered} unanswered)").dim())
} else {
Line::from(base.dim())
}
} else {
Line::from("No questions".dim())
};
Paragraph::new(progress_line).render(sections.progress_area, buf);
// Question prompt text.
let question_y = sections.question_area.y;
let answered =
self.is_question_answered(self.current_index(), &self.composer.current_text());
for (offset, line) in sections.question_lines.iter().enumerate() {
if question_y.saturating_add(offset as u16)
>= sections.question_area.y + sections.question_area.height
{
break;
}
let question_line = if answered {
Line::from(line.clone())
} else {
Line::from(line.clone()).cyan()
};
Paragraph::new(question_line).render(
Rect {
x: sections.question_area.x,
y: question_y.saturating_add(offset as u16),
width: sections.question_area.width,
height: 1,
},
buf,
);
}
// Build rows with selection markers for the shared selection renderer.
let option_rows = self.option_rows();
if self.has_options() {
let mut options_state = self
.current_answer()
.map(|answer| answer.options_state)
.unwrap_or_default();
if sections.options_area.height > 0 {
// Ensure the selected option is visible in the scroll window.
options_state
.ensure_visible(option_rows.len(), sections.options_area.height as usize);
render_rows_bottom_aligned(
sections.options_area,
buf,
&option_rows,
&options_state,
option_rows.len().max(1),
"No options",
);
}
}
if notes_visible && sections.notes_area.height > 0 {
self.render_notes_input(sections.notes_area, buf);
}
let footer_y = sections
.notes_area
.y
.saturating_add(sections.notes_area.height);
let footer_area = Rect {
x: content_area.x,
y: footer_y,
width: content_area.width,
height: sections.footer_lines,
};
if footer_area.height == 0 {
return;
}
let options_hidden = self.has_options()
&& sections.options_area.height > 0
&& self.options_required_height(content_area.width) > sections.options_area.height;
let option_tip = if options_hidden {
let selected = self.selected_option_index().unwrap_or(0).saturating_add(1);
let total = self.options_len();
Some(super::FooterTip::new(format!("option {selected}/{total}")))
} else {
None
};
let tip_lines = self.footer_tip_lines_with_prefix(footer_area.width, option_tip);
for (row_idx, tips) in tip_lines
.into_iter()
.take(footer_area.height as usize)
.enumerate()
{
let mut spans = Vec::new();
for (tip_idx, tip) in tips.into_iter().enumerate() {
if tip_idx > 0 {
spans.push(TIP_SEPARATOR.into());
}
if tip.highlight {
spans.push(tip.text.cyan().bold().not_dim());
} else {
spans.push(tip.text.into());
}
}
let line = Line::from(spans).dim();
let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize);
let row_area = Rect {
x: footer_area.x,
y: footer_area.y.saturating_add(row_idx as u16),
width: footer_area.width,
height: 1,
};
Paragraph::new(line).render(row_area, buf);
}
}
/// Return the cursor position when editing notes, if visible.
pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> {
if self.confirm_unanswered_active() {
return None;
}
let has_options = self.has_options();
let notes_visible = self.notes_ui_visible();
if !self.focus_is_notes() {
return None;
}
if has_options && !notes_visible {
return None;
}
let content_area = menu_surface_inset(area);
if content_area.width == 0 || content_area.height == 0 {
return None;
}
let sections = self.layout_sections(content_area);
let input_area = sections.notes_area;
if input_area.width == 0 || input_area.height == 0 {
return None;
}
self.composer.cursor_pos(input_area)
}
/// Render the notes composer.
fn render_notes_input(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let is_secret = self
.current_question()
.is_some_and(|question| question.is_secret);
if is_secret {
self.composer.render_with_mask(area, buf, Some('*'));
} else {
self.composer.render(area, buf);
}
}
}
fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
/// Render rows into `area`, bottom-aligning the visible rows when fewer than
/// `area.height` lines are produced.
///
/// This keeps footer spacing stable by anchoring the options block to the
/// bottom of its allocated region.
fn render_rows_bottom_aligned(
area: Rect,
buf: &mut Buffer,
rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) {
if area.width == 0 || area.height == 0 {
return;
}
let scratch_area = Rect::new(0, 0, area.width, area.height);
let mut scratch = Buffer::empty(scratch_area);
for y in 0..area.height {
for x in 0..area.width {
scratch[(x, y)] = buf[(area.x + x, area.y + y)].clone();
}
}
let rendered_height = render_rows(
scratch_area,
&mut scratch,
rows,
state,
max_results,
empty_message,
);
let visible_height = rendered_height.min(area.height);
let y_offset = area.height.saturating_sub(visible_height);
for y in 0..visible_height {
for x in 0..area.width {
buf[(area.x + x, area.y + y_offset + y)] = scratch[(x, y)].clone();
}
}
}
/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis.
///
/// This walks spans character-by-character, tracking the last width-safe position and the last
/// whitespace boundary within the available width (excluding the ellipsis width). If the line
/// overflows, it truncates at the last word boundary when possible (falling back to the last
/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the
/// last visible span (or the line style if nothing was kept).
fn truncate_line_word_boundary_with_ellipsis(
line: Line<'static>,
max_width: usize,
) -> Line<'static> {
if max_width == 0 {
return Line::from(Vec::<Span<'static>>::new());
}
if line_width(&line) <= max_width {
return line;
}
let ellipsis = "…";
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
if ellipsis_width >= max_width {
return Line::from(ellipsis);
}
let limit = max_width.saturating_sub(ellipsis_width);
#[derive(Clone, Copy)]
struct BreakPoint {
span_idx: usize,
byte_end: usize,
}
// Track display width as we scan, along with the best "cut here" positions.
let mut used = 0usize;
let mut last_fit: Option<BreakPoint> = None;
let mut last_word_break: Option<BreakPoint> = None;
let mut overflowed = false;
'outer: for (span_idx, span) in line.spans.iter().enumerate() {
let text = span.content.as_ref();
for (byte_idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used.saturating_add(ch_width) > limit {
overflowed = true;
break 'outer;
}
used = used.saturating_add(ch_width);
let bp = BreakPoint {
span_idx,
byte_end: byte_idx + ch.len_utf8(),
};
last_fit = Some(bp);
if ch.is_whitespace() {
last_word_break = Some(bp);
}
}
}
// If we never overflowed, the original line already fits.
if !overflowed {
return line;
}
// Prefer breaking on whitespace; otherwise fall back to the last fitting character.
let chosen_break = last_word_break.or(last_fit);
let Some(chosen_break) = chosen_break else {
return Line::from(ellipsis);
};
let line_style = line.style;
let mut spans_out: Vec<Span<'static>> = Vec::new();
for (idx, span) in line.spans.into_iter().enumerate() {
if idx < chosen_break.span_idx {
spans_out.push(span);
continue;
}
if idx == chosen_break.span_idx {
let text = span.content.into_owned();
let truncated = text[..chosen_break.byte_end].to_string();
if !truncated.is_empty() {
spans_out.push(Span::styled(truncated, span.style));
}
}
break;
}
while let Some(last) = spans_out.last_mut() {
let trimmed = last
.content
.trim_end_matches(char::is_whitespace)
.to_string();
if trimmed.is_empty() {
spans_out.pop();
} else {
last.content = trimmed.into();
break;
}
}
let ellipsis_style = spans_out
.last()
.map(|span| span.style)
.unwrap_or(line_style);
spans_out.push(Span::styled(ellipsis, ellipsis_style));
Line::from(spans_out).style(line_style)
}
@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2600
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Choose an option.
1. Option 1 First choice.
› 2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer
←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
enter to submit answer | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
What would you like to do next?
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
› 4. Refactor Tighten structure and remove dead code.
option 4/5 | tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose one option.
› 1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for
unknown (Recommended when triaging long-running background work and status progress tracking and include
transitions) enough context for debugging
retries, stale workers, and
unexpected expiration paths.
2. Add a short status model Simpler labels with less detail for
quick rollouts.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2744
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,16 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2770
expression: "render_snapshot(&overlay, area)"
---
Question 2/2 (2 unanswered)
Share details.
› Type your answer (optional)
enter to submit all | ctrl + p / ctrl + n change question | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2321
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
› Add notes
tab or esc to clear notes | enter to submit answer
@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
What would you like to do next?
1. Discuss a code change (Recommended) Walk through a plan and edit code together.
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
› 4. Refactor Tighten structure and remove dead code.
5. Ship it Finalize and open a PR.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,15 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Submit with unanswered questions?
2 unanswered questions
› 1. Proceed Submit with 2 unanswered questions.
2. Go back Return to the first unanswered question.
Press enter to confirm or esc to go back
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose the next step for this task.
› 1. Discuss a code change Walk through a plan, then implement it together with careful checks.
2. Run targeted tests Pick the most relevant crate and validate the current behavior first.
3. Review the diff Summarize the changes and highlight the most important risks and gaps.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,14 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Choose an option.
1. Option 1 First choice.
› 2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer
←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
enter to submit answer | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
What would you like to do next?
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
› 4. Refactor Tighten structure and remove dead code.
option 4/5 | tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,17 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose one option.
› 1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for
unknown (Recommended when triaging long-running background work and status progress tracking and include
transitions) enough context for debugging
retries, stale workers, and
unexpected expiration paths.
2. Add a short status model Simpler labels with less detail for
quick rollouts.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,15 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 2/2 (2 unanswered)
Share details.
› Type your answer (optional)
enter to submit all | ctrl + p / ctrl + n change question | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,19 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
› Add notes
tab or esc to clear notes | enter to submit answer
@@ -0,0 +1,15 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
What would you like to do next?
1. Discuss a code change (Recommended) Walk through a plan and edit code together.
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
› 4. Refactor Tighten structure and remove dead code.
5. Ship it Finalize and open a PR.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose an option.
› 1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,15 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Submit with unanswered questions?
2 unanswered questions
› 1. Proceed Submit with 2 unanswered questions.
2. Go back Return to the first unanswered question.
Press enter to confirm or esc to go back
@@ -0,0 +1,13 @@
---
source: tui_app_server/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Choose the next step for this task.
› 1. Discuss a code change Walk through a plan, then implement it together with careful checks.
2. Run targeted tests Pick the most relevant crate and validate the current behavior first.
3. Review the diff Summarize the changes and highlight the most important risks and gaps.
tab to add notes | enter to submit answer | esc to interrupt
@@ -0,0 +1,115 @@
/// Generic scroll/selection state for a vertical list menu.
///
/// Encapsulates the common behavior of a selectable list that supports:
/// - Optional selection (None when list is empty)
/// - Wrap-around navigation on Up/Down
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
pub scroll_top: usize,
}
impl ScrollState {
pub fn new() -> Self {
Self {
selected_idx: None,
scroll_top: 0,
}
}
/// Reset selection and scroll.
pub fn reset(&mut self) {
self.selected_idx = None;
self.scroll_top = 0;
}
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
};
if len == 0 {
self.scroll_top = 0;
}
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx > 0 => idx - 1,
Some(_) => len - 1,
None => 0,
});
}
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx + 1 < len => idx + 1,
_ => 0,
});
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
if len == 0 || visible_rows == 0 {
self.scroll_top = 0;
return;
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::ScrollState;
#[test]
fn wrap_navigation_and_visibility() {
let mut s = ScrollState::new();
let len = 10;
let vis = 5;
s.clamp_selection(len);
assert_eq!(s.selected_idx, Some(0));
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
}
@@ -0,0 +1,869 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use super::scroll_state::ScrollState;
/// Render-ready representation of one row in a selection popup.
///
/// This type contains presentation-focused fields that are intentionally more
/// concrete than source domain models. `match_indices` are character offsets
/// into `name`, and `wrap_indent` is interpreted in terminal cell columns.
#[derive(Default)]
pub(crate) struct GenericDisplayRow {
pub name: String,
pub name_prefix_spans: Vec<Span<'static>>,
pub display_shortcut: Option<KeyBinding>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub category_tag: Option<String>, // optional right-side category label
pub disabled_reason: Option<String>, // optional disabled message
pub is_disabled: bool,
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
/// Controls how selection rows choose the split between left/right name/description columns.
///
/// Callers should use the same mode for both measurement and rendering, or the
/// popup can reserve the wrong number of lines and clip content.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) enum ColumnWidthMode {
/// Derive column placement from only the visible viewport rows.
#[default]
AutoVisible,
/// Derive column placement from all rows so scrolling does not shift columns.
AutoAllRows,
/// Use a fixed two-column split: 30% left (name), 70% right (description).
Fixed,
}
// Fixed split used by explicitly fixed column mode: 30% label, 70%
// description.
const FIXED_LEFT_COLUMN_NUMERATOR: usize = 3;
const FIXED_LEFT_COLUMN_DENOMINATOR: usize = 10;
const MENU_SURFACE_INSET_V: u16 = 1;
const MENU_SURFACE_INSET_H: u16 = 2;
/// Apply the shared "menu surface" padding used by bottom-pane overlays.
///
/// Rendering code should generally call [`render_menu_surface`] and then lay
/// out content inside the returned inset rect.
pub(crate) fn menu_surface_inset(area: Rect) -> Rect {
area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H))
}
/// Total vertical padding introduced by the menu surface treatment.
pub(crate) const fn menu_surface_padding_height() -> u16 {
MENU_SURFACE_INSET_V * 2
}
/// Paint the shared menu background and return the inset content area.
///
/// This keeps the surface treatment consistent across selection-style overlays
/// (for example `/model`, approvals, and request-user-input). Callers should
/// render all inner content in the returned rect, not the original area.
pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect {
if area.is_empty() {
return area;
}
Block::default()
.style(user_message_style())
.render(area, buf);
menu_surface_inset(area)
}
/// Wrap a styled line while preserving span styles.
///
/// The function clamps `width` to at least one terminal cell so callers can use
/// it safely with narrow layouts.
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let width = width.max(1) as usize;
let opts = RtOptions::new(width)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(""));
word_wrap_line(line, opts)
}
fn line_to_owned(line: Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.into_iter()
.map(|span| Span {
style: span.style,
content: Cow::Owned(span.content.into_owned()),
})
.collect(),
}
}
fn compute_desc_col(
rows_all: &[GenericDisplayRow],
start_idx: usize,
visible_items: usize,
content_width: u16,
col_width_mode: ColumnWidthMode,
) -> usize {
if content_width <= 1 {
return 0;
}
let max_desc_col = content_width.saturating_sub(1) as usize;
// Reuse the existing fixed split constants to derive the auto cap:
// if fixed mode is 30/70 (label/description), auto mode caps label width
// at 70% to keep at least 30% available for descriptions.
let max_auto_desc_col = max_desc_col.min(
((content_width as usize * (FIXED_LEFT_COLUMN_DENOMINATOR - FIXED_LEFT_COLUMN_NUMERATOR))
/ FIXED_LEFT_COLUMN_DENOMINATOR)
.max(1),
);
match col_width_mode {
ColumnWidthMode::Fixed => ((content_width as usize * FIXED_LEFT_COLUMN_NUMERATOR)
/ FIXED_LEFT_COLUMN_DENOMINATOR)
.clamp(1, max_desc_col),
ColumnWidthMode::AutoVisible | ColumnWidthMode::AutoAllRows => {
let max_name_width = match col_width_mode {
ColumnWidthMode::AutoVisible => rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.map(|(_, row)| {
let mut spans = row.name_prefix_spans.clone();
spans.push(row.name.clone().into());
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
Line::from(spans).width()
})
.max()
.unwrap_or(0),
ColumnWidthMode::AutoAllRows => rows_all
.iter()
.map(|row| {
let mut spans = row.name_prefix_spans.clone();
spans.push(row.name.clone().into());
if row.disabled_reason.is_some() {
spans.push(" (disabled)".dim());
}
Line::from(spans).width()
})
.max()
.unwrap_or(0),
ColumnWidthMode::Fixed => 0,
};
max_name_width.saturating_add(2).min(max_auto_desc_col)
}
}
}
/// Determine how many spaces to indent wrapped lines for a row.
fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize {
let max_indent = max_width.saturating_sub(1) as usize;
let indent = row.wrap_indent.unwrap_or_else(|| {
if row.description.is_some() || row.disabled_reason.is_some() {
desc_col
} else {
0
}
});
indent.min(max_indent)
}
fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool {
// This path intentionally targets plain option rows that opt into wrapped
// labels. Styled/fuzzy-matched rows keep the legacy combined-line path.
row.wrap_indent.is_some()
&& row.description.is_some()
&& row.disabled_reason.is_none()
&& row.match_indices.is_none()
&& row.display_shortcut.is_none()
&& row.category_tag.is_none()
&& row.name_prefix_spans.is_empty()
}
fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
let Some(description) = row.description.as_deref() else {
return Vec::new();
};
let width = width.max(1);
let max_desc_col = width.saturating_sub(1) as usize;
if max_desc_col == 0 {
// No valid description column exists at this width; let callers fall
// back to single-line wrapping path.
return Vec::new();
}
let desc_col = desc_col.clamp(1, max_desc_col);
let left_width = desc_col.saturating_sub(2).max(1);
let right_width = width.saturating_sub(desc_col as u16).max(1) as usize;
let name_wrap_indent = row
.wrap_indent
.unwrap_or(0)
.min(left_width.saturating_sub(1));
let name_subsequent_indent = " ".repeat(name_wrap_indent);
let name_options = textwrap::Options::new(left_width)
.initial_indent("")
.subsequent_indent(name_subsequent_indent.as_str());
let name_lines = textwrap::wrap(row.name.as_str(), name_options);
let desc_options = textwrap::Options::new(right_width).initial_indent("");
let desc_lines = textwrap::wrap(description, desc_options);
let rows = name_lines.len().max(desc_lines.len()).max(1);
let mut out = Vec::with_capacity(rows);
for idx in 0..rows {
let mut spans: Vec<Span<'static>> = Vec::new();
if let Some(name) = name_lines.get(idx) {
spans.push(name.to_string().into());
}
if let Some(desc) = desc_lines.get(idx) {
let left_used = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum::<usize>();
let gap = if left_used == 0 {
desc_col
} else {
desc_col.saturating_sub(left_used).max(2)
};
if gap > 0 {
spans.push(" ".repeat(gap).into());
}
spans.push(desc.to_string().dim());
}
out.push(Line::from(spans));
}
out
}
fn wrap_standard_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let full_line = build_full_line(row, desc_col);
let continuation_indent = wrap_indent(row, desc_col, width);
let options = RtOptions::new(width.max(1) as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(continuation_indent)));
word_wrap_line(&full_line, options)
.into_iter()
.map(line_to_owned)
.collect()
}
fn wrap_row_lines(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
if should_wrap_name_in_column(row) {
let wrapped = wrap_two_column_row(row, desc_col, width);
if !wrapped.is_empty() {
return wrapped;
}
}
wrap_standard_row(row, desc_col, width)
}
fn apply_row_state_style(lines: &mut [Line<'static>], selected: bool, is_disabled: bool) {
if selected {
for line in lines.iter_mut() {
line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
}
if is_disabled {
for line in lines.iter_mut() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
}
}
fn compute_item_window_start(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_items: usize,
) -> usize {
if rows_all.is_empty() || max_items == 0 {
return 0;
}
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else {
let bottom = start_idx.saturating_add(max_items.saturating_sub(1));
if sel > bottom {
start_idx = sel + 1 - max_items;
}
}
}
start_idx
}
fn is_selected_visible_in_wrapped_viewport(
rows_all: &[GenericDisplayRow],
start_idx: usize,
max_items: usize,
selected_idx: usize,
desc_col: usize,
width: u16,
viewport_height: u16,
) -> bool {
if viewport_height == 0 {
return false;
}
let mut used_lines = 0usize;
let viewport_height = viewport_height as usize;
for (idx, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
let row_lines = wrap_row_lines(row, desc_col, width).len().max(1);
// Keep rendering semantics in sync: always show the first row, even if
// it overflows the viewport.
if used_lines > 0 && used_lines.saturating_add(row_lines) > viewport_height {
break;
}
if idx == selected_idx {
return true;
}
used_lines = used_lines.saturating_add(row_lines);
if used_lines >= viewport_height {
break;
}
}
false
}
fn adjust_start_for_wrapped_selection_visibility(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_items: usize,
desc_measure_items: usize,
width: u16,
viewport_height: u16,
col_width_mode: ColumnWidthMode,
) -> usize {
let mut start_idx = compute_item_window_start(rows_all, state, max_items);
let Some(sel) = state.selected_idx else {
return start_idx;
};
if viewport_height == 0 {
return start_idx;
}
// If wrapped row heights push the selected item out of view, advance the
// item window until the selected row is visible.
while start_idx < sel {
let desc_col = compute_desc_col(
rows_all,
start_idx,
desc_measure_items,
width,
col_width_mode,
);
if is_selected_visible_in_wrapped_viewport(
rows_all,
start_idx,
max_items,
sel,
desc_col,
width,
viewport_height,
) {
break;
}
start_idx = start_idx.saturating_add(1);
}
start_idx
}
/// Build the full display line for a row with the description padded to start
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
/// dims the description.
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let combined_description = match (&row.description, &row.disabled_reason) {
(Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")),
(Some(desc), None) => Some(desc.clone()),
(None, Some(reason)) => Some(format!("disabled: {reason}")),
(None, None) => None,
};
// Enforce single-line name: allow at most desc_col - 2 cells for name,
// reserving two spaces before the description column.
let name_prefix_width = Line::from(row.name_prefix_spans.clone()).width();
let name_limit = combined_description
.as_ref()
.map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width))
.unwrap_or(usize::MAX);
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
let mut used_width = 0usize;
let mut truncated = false;
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
}
} else {
for ch in row.name.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
name_spans.push(ch.to_string().into());
}
}
if truncated {
// If there is at least one cell available, add an ellipsis.
// When name_limit is 0, we still show an ellipsis to indicate truncation.
name_spans.push("…".into());
}
if row.disabled_reason.is_some() {
name_spans.push(" (disabled)".dim());
}
let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = row.name_prefix_spans.clone();
full_spans.extend(name_spans);
if let Some(display_shortcut) = row.display_shortcut {
full_spans.push(" (".into());
full_spans.push(display_shortcut.into());
full_spans.push(")".into());
}
if let Some(desc) = combined_description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
if gap > 0 {
full_spans.push(" ".repeat(gap).into());
}
full_spans.push(desc.clone().dim());
}
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
full_spans.push(" ".into());
full_spans.push(tag.to_string().dim());
}
Line::from(full_spans)
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// Returns the number of terminal lines actually rendered (including the
/// single-line empty placeholder when shown).
fn render_rows_inner(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
col_width_mode: ColumnWidthMode,
) -> u16 {
if rows_all.is_empty() {
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
}
// Count the placeholder line only when there is vertical space to draw it.
return u16::from(area.height > 0);
}
let max_items = max_results.min(rows_all.len());
if max_items == 0 {
return 0;
}
let desc_measure_items = max_items.min(area.height.max(1) as usize);
// Keep item-window semantics, then correct for wrapped row heights so the
// selected row remains visible in a line-based viewport.
let start_idx = adjust_start_for_wrapped_selection_visibility(
rows_all,
state,
max_items,
desc_measure_items,
area.width,
area.height,
col_width_mode,
);
let desc_col = compute_desc_col(
rows_all,
start_idx,
desc_measure_items,
area.width,
col_width_mode,
);
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
let mut cur_y = area.y;
let mut rendered_lines: u16 = 0;
for (i, row) in rows_all.iter().enumerate().skip(start_idx).take(max_items) {
if cur_y >= area.y + area.height {
break;
}
let mut wrapped = wrap_row_lines(row, desc_col, area.width);
apply_row_state_style(
&mut wrapped,
Some(i) == state.selected_idx && !row.is_disabled,
row.is_disabled,
);
// Render the wrapped lines.
for line in wrapped {
if cur_y >= area.y + area.height {
break;
}
line.render(
Rect {
x: area.x,
y: cur_y,
width: area.width,
height: 1,
},
buf,
);
cur_y = cur_y.saturating_add(1);
rendered_lines = rendered_lines.saturating_add(1);
}
}
rendered_lines
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// Description alignment is computed from visible rows only, which allows the
/// layout to adapt tightly to the current viewport.
///
/// This function should be paired with [`measure_rows_height`] when reserving
/// space; pairing it with a different measurement mode can cause clipping.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
render_rows_inner(
area,
buf,
rows_all,
state,
max_results,
empty_message,
ColumnWidthMode::AutoVisible,
)
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
/// This mode keeps column placement stable while scrolling by sizing the
/// description column against the full dataset.
///
/// This function should be paired with
/// [`measure_rows_height_stable_col_widths`] so reserved and rendered heights
/// stay in sync.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_stable_col_widths(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
render_rows_inner(
area,
buf,
rows_all,
state,
max_results,
empty_message,
ColumnWidthMode::AutoAllRows,
)
}
/// Render a list of rows using the provided ScrollState and explicit
/// [`ColumnWidthMode`] behavior.
///
/// This is the low-level entry point for callers that need to thread a mode
/// through higher-level configuration.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_with_col_width_mode(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
col_width_mode: ColumnWidthMode,
) -> u16 {
render_rows_inner(
area,
buf,
rows_all,
state,
max_results,
empty_message,
col_width_mode,
)
}
/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis.
///
/// This path always uses viewport-local width alignment and is best for dense
/// list UIs where multi-line descriptions would add too much vertical churn.
/// Returns the number of terminal lines actually rendered.
pub(crate) fn render_rows_single_line(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
empty_message: &str,
) -> u16 {
if rows_all.is_empty() {
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
}
// Count the placeholder line only when there is vertical space to draw it.
return u16::from(area.height > 0);
}
let visible_items = max_results
.min(rows_all.len())
.min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
}
let desc_col = compute_desc_col(
rows_all,
start_idx,
visible_items,
area.width,
ColumnWidthMode::AutoVisible,
);
let mut cur_y = area.y;
let mut rendered_lines: u16 = 0;
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
{
if cur_y >= area.y + area.height {
break;
}
let mut full_line = build_full_line(row, desc_col);
if Some(i) == state.selected_idx && !row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
if row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize);
full_line.render(
Rect {
x: area.x,
y: cur_y,
width: area.width,
height: 1,
},
buf,
);
cur_y = cur_y.saturating_add(1);
rendered_lines = rendered_lines.saturating_add(1);
}
rendered_lines
}
/// Compute the number of terminal rows required to render up to `max_results`
/// items from `rows_all` given the current scroll/selection state and the
/// available `width`. Accounts for description wrapping and alignment so the
/// caller can allocate sufficient vertical space.
///
/// This function matches [`render_rows`] semantics (`AutoVisible` column
/// sizing). Mixing it with stable or fixed render modes can under- or
/// over-estimate required height.
pub(crate) fn measure_rows_height(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
) -> u16 {
measure_rows_height_inner(
rows_all,
state,
max_results,
width,
ColumnWidthMode::AutoVisible,
)
}
/// Measures selection-row height while using full-dataset column alignment.
/// This should be paired with [`render_rows_stable_col_widths`] so layout
/// reservation matches rendering behavior.
pub(crate) fn measure_rows_height_stable_col_widths(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
) -> u16 {
measure_rows_height_inner(
rows_all,
state,
max_results,
width,
ColumnWidthMode::AutoAllRows,
)
}
/// Measure selection-row height using explicit [`ColumnWidthMode`] behavior.
///
/// This is the low-level companion to [`render_rows_with_col_width_mode`].
pub(crate) fn measure_rows_height_with_col_width_mode(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
col_width_mode: ColumnWidthMode,
) -> u16 {
measure_rows_height_inner(rows_all, state, max_results, width, col_width_mode)
}
fn measure_rows_height_inner(
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
width: u16,
col_width_mode: ColumnWidthMode,
) -> u16 {
if rows_all.is_empty() {
return 1; // placeholder "no matches" line
}
let content_width = width.saturating_sub(1).max(1);
let visible_items = max_results.min(rows_all.len());
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
}
let desc_col = compute_desc_col(
rows_all,
start_idx,
visible_items,
content_width,
col_width_mode,
);
let mut total: u16 = 0;
for row in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.map(|(_, r)| r)
{
let wrapped_lines = wrap_row_lines(row, desc_col, content_width).len();
total = total.saturating_add(wrapped_lines as u16);
}
total.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn one_cell_width_falls_back_without_panic_for_wrapped_two_column_rows() {
let row = GenericDisplayRow {
name: "1. Very long option label".to_string(),
description: Some("Very long description".to_string()),
wrap_indent: Some(4),
..Default::default()
};
let two_col = wrap_two_column_row(&row, 0, 1);
assert_eq!(two_col.len(), 0);
}
}
@@ -0,0 +1,229 @@
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt;
use crate::text_formatting::truncate_text;
use codex_utils_fuzzy_match::fuzzy_match;
#[derive(Clone, Debug)]
pub(crate) struct MentionItem {
pub(crate) display_name: String,
pub(crate) description: Option<String>,
pub(crate) insert_text: String,
pub(crate) search_terms: Vec<String>,
pub(crate) path: Option<String>,
pub(crate) category_tag: Option<String>,
pub(crate) sort_rank: u8,
}
const MENTION_NAME_TRUNCATE_LEN: usize = 24;
pub(crate) struct SkillPopup {
query: String,
mentions: Vec<MentionItem>,
state: ScrollState,
}
impl SkillPopup {
pub(crate) fn new(mentions: Vec<MentionItem>) -> Self {
Self {
query: String::new(),
mentions,
state: ScrollState::new(),
}
}
pub(crate) fn set_mentions(&mut self, mentions: Vec<MentionItem>) {
self.mentions = mentions;
self.clamp_selection();
}
pub(crate) fn set_query(&mut self, query: &str) {
self.query = query.to_string();
self.clamp_selection();
}
pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 {
let rows = self.rows_from_matches(self.filtered());
let visible = rows.len().clamp(1, MAX_POPUP_ROWS);
(visible as u16).saturating_add(2)
}
pub(crate) fn move_up(&mut self) {
let len = self.filtered_items().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn move_down(&mut self) {
let len = self.filtered_items().len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
pub(crate) fn selected_mention(&self) -> Option<&MentionItem> {
let matches = self.filtered_items();
let idx = self.state.selected_idx?;
let mention_idx = matches.get(idx)?;
self.mentions.get(*mention_idx)
}
fn clamp_selection(&mut self) {
let len = self.filtered_items().len();
self.state.clamp_selection(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
fn filtered_items(&self) -> Vec<usize> {
self.filtered().into_iter().map(|(idx, _, _)| idx).collect()
}
fn rows_from_matches(
&self,
matches: Vec<(usize, Option<Vec<usize>>, i32)>,
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(idx, indices, _score)| {
let mention = &self.mentions[idx];
let name = truncate_text(&mention.display_name, MENTION_NAME_TRUNCATE_LEN);
let description = match (
mention.category_tag.as_deref(),
mention.description.as_deref(),
) {
(Some(tag), Some(description)) if !description.is_empty() => {
Some(format!("{tag} {description}"))
}
(Some(tag), _) => Some(tag.to_string()),
(None, Some(description)) if !description.is_empty() => {
Some(description.to_string())
}
_ => None,
};
GenericDisplayRow {
name,
name_prefix_spans: Vec::new(),
match_indices: indices,
display_shortcut: None,
description,
category_tag: None,
is_disabled: false,
disabled_reason: None,
wrap_indent: None,
}
})
.collect()
}
fn filtered(&self) -> Vec<(usize, Option<Vec<usize>>, i32)> {
let filter = self.query.trim();
let mut out: Vec<(usize, Option<Vec<usize>>, i32)> = Vec::new();
for (idx, mention) in self.mentions.iter().enumerate() {
if filter.is_empty() {
out.push((idx, None, 0));
continue;
}
let mut best_match: Option<(Option<Vec<usize>>, i32)> = None;
if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) {
best_match = Some((Some(indices), score));
}
for term in &mention.search_terms {
if term == &mention.display_name {
continue;
}
if let Some((_indices, score)) = fuzzy_match(term, filter) {
match best_match.as_mut() {
Some((best_indices, best_score)) => {
if score > *best_score {
*best_score = score;
*best_indices = None;
}
}
None => {
best_match = Some((None, score));
}
}
}
}
if let Some((indices, score)) = best_match {
out.push((idx, indices, score));
}
}
out.sort_by(|a, b| {
self.mentions[a.0]
.sort_rank
.cmp(&self.mentions[b.0].sort_rank)
.then_with(|| a.2.cmp(&b.2))
.then_with(|| {
let an = self.mentions[a.0].display_name.as_str();
let bn = self.mentions[b.0].display_name.as_str();
an.cmp(bn)
})
});
out
}
}
impl WidgetRef for SkillPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let (list_area, hint_area) = if area.height > 2 {
let [list_area, _spacer_area, hint_area] = Layout::vertical([
Constraint::Length(area.height - 2),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area);
(list_area, Some(hint_area))
} else {
(area, None)
};
let rows = self.rows_from_matches(self.filtered());
render_rows_single_line(
list_area.inset(Insets::tlbr(0, 2, 0, 0)),
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
);
if let Some(hint_area) = hint_area {
let hint_area = Rect {
x: hint_area.x + 2,
y: hint_area.y,
width: hint_area.width.saturating_sub(2),
height: hint_area.height,
};
skill_popup_hint_line().render(hint_area, buf);
}
}
}
fn skill_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to insert or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}
@@ -0,0 +1,433 @@
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::skills_helpers::match_skill;
use crate::skills_helpers::truncate_skill_name;
use crate::style::user_message_style;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows_single_line;
const SEARCH_PLACEHOLDER: &str = "Type to search skills";
const SEARCH_PROMPT_PREFIX: &str = "> ";
pub(crate) struct SkillsToggleItem {
pub name: String,
pub skill_name: String,
pub description: String,
pub enabled: bool,
pub path: PathBuf,
}
pub(crate) struct SkillsToggleView {
items: Vec<SkillsToggleItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
search_query: String,
filtered_indices: Vec<usize>,
}
impl SkillsToggleView {
pub(crate) fn new(items: Vec<SkillsToggleItem>, app_event_tx: AppEventSender) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Enable/Disable Skills".bold()));
header.push(Line::from(
"Turn skills on or off. Your changes are saved automatically.".dim(),
));
let mut view = Self {
items,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: skills_toggle_hint_line(),
search_query: String::new(),
filtered_indices: Vec::new(),
};
view.apply_filter();
view
}
fn visible_len(&self) -> usize {
self.filtered_indices.len()
}
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
}
fn apply_filter(&mut self) {
// Filter + sort while preserving the current selection when possible.
let previously_selected = self
.state
.selected_idx
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied());
let filter = self.search_query.trim();
if filter.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let mut matches: Vec<(usize, i32)> = Vec::new();
for (idx, item) in self.items.iter().enumerate() {
let display_name = item.name.as_str();
if let Some((_indices, score)) = match_skill(filter, display_name, &item.skill_name)
{
matches.push((idx, score));
}
}
matches.sort_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
let an = self.items[a.0].name.as_str();
let bn = self.items[b.0].name.as_str();
an.cmp(bn)
})
});
self.filtered_indices = matches.into_iter().map(|(idx, _score)| idx).collect();
}
let len = self.filtered_indices.len();
self.state.selected_idx = previously_selected
.and_then(|actual_idx| {
self.filtered_indices
.iter()
.position(|idx| *idx == actual_idx)
})
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '›' } else { ' ' };
let marker = if item.enabled { 'x' } else { ' ' };
let item_name = truncate_skill_name(&item.name);
let name = format!("{prefix} [{marker}] {item_name}");
GenericDisplayRow {
name,
description: Some(item.description.clone()),
..Default::default()
}
})
})
.collect()
}
fn move_up(&mut self) {
let len = self.visible_len();
self.state.move_up_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn move_down(&mut self) {
let len = self.visible_len();
self.state.move_down_wrap(len);
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn toggle_selected(&mut self) {
let Some(idx) = self.state.selected_idx else {
return;
};
let Some(actual_idx) = self.filtered_indices.get(idx).copied() else {
return;
};
let Some(item) = self.items.get_mut(actual_idx) else {
return;
};
item.enabled = !item.enabled;
self.app_event_tx.send(AppEvent::SetSkillEnabled {
path: item.path.clone(),
enabled: item.enabled,
});
}
fn close(&mut self) {
if self.complete {
return;
}
self.complete = true;
self.app_event_tx.send(AppEvent::ManageSkillsClosed);
self.app_event_tx.list_skills(Vec::new(), true);
}
fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
fn rows_height(&self, rows: &[GenericDisplayRow]) -> u16 {
rows.len().clamp(1, MAX_POPUP_ROWS).try_into().unwrap_or(1)
}
}
impl BottomPaneView for SkillsToggleView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
} => {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.search_query.push(c);
self.apply_filter();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.close();
CancellationEvent::Handled
}
}
impl Renderable for SkillsToggleView {
fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_height = self.rows_height(&rows);
let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height = height.saturating_add(2);
height.saturating_add(1)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
// Reserve the footer line for the key-hint row.
let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Block::default()
.style(user_message_style())
.render(content_area, buf);
let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = self.rows_height(&rows);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(2),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
self.header.render(header_area, buf);
// Render the search prompt as two lines to mimic the composer.
if search_area.height >= 2 {
let [placeholder_area, input_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(search_area);
Line::from(SEARCH_PLACEHOLDER.dim()).render(placeholder_area, buf);
let line = if self.search_query.is_empty() {
Line::from(vec![SEARCH_PROMPT_PREFIX.dim()])
} else {
Line::from(vec![
SEARCH_PROMPT_PREFIX.dim(),
self.search_query.clone().into(),
])
};
line.render(input_area, buf);
} else if search_area.height > 0 {
let query_span = if self.search_query.is_empty() {
SEARCH_PLACEHOLDER.dim()
} else {
self.search_query.clone().into()
};
Line::from(query_span).render(search_area, buf);
}
if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows_single_line(
render_area,
buf,
&rows,
&self.state,
render_area.height as usize,
"no matches",
);
}
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}
}
fn skills_toggle_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
fn render_lines(view: &SkillsToggleView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let lines: Vec<String> = (0..area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect();
lines.join("\n")
}
#[test]
fn renders_basic_popup() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![
SkillsToggleItem {
name: "Repo Scout".to_string(),
skill_name: "repo_scout".to_string(),
description: "Summarize the repo layout".to_string(),
enabled: true,
path: PathBuf::from("/tmp/skills/repo_scout.toml"),
},
SkillsToggleItem {
name: "Changelog Writer".to_string(),
skill_name: "changelog_writer".to_string(),
description: "Draft release notes".to_string(),
enabled: false,
path: PathBuf::from("/tmp/skills/changelog_writer.toml"),
},
];
let view = SkillsToggleView::new(items, tx);
assert_snapshot!("skills_toggle_basic", render_lines(&view, 72));
}
}
@@ -0,0 +1,132 @@
//! Shared helpers for filtering and matching built-in slash commands.
//!
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
//! and ensures they stay in sync.
use std::str::FromStr;
use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct BuiltinCommandFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) connectors_enabled: bool,
pub(crate) fast_command_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) allow_elevate_sandbox: bool,
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| {
flags.collaboration_modes_enabled
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
let cmd = SlashCommand::from_str(name).ok()?;
builtins_for_input(flags)
.into_iter()
.any(|(_, visible_cmd)| visible_cmd == cmd)
.then_some(cmd)
}
/// Whether any visible built-in fuzzily matches the provided prefix.
pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool {
builtins_for_input(flags)
.into_iter()
.any(|(command_name, _)| fuzzy_match(command_name, name).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn all_enabled_flags() -> BuiltinCommandFlags {
BuiltinCommandFlags {
collaboration_modes_enabled: true,
connectors_enabled: true,
fast_command_enabled: true,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: true,
allow_elevate_sandbox: true,
}
}
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", all_enabled_flags());
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}
#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", all_enabled_flags()),
Some(SlashCommand::Clear)
);
}
#[test]
fn stop_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("stop", all_enabled_flags()),
Some(SlashCommand::Stop)
);
}
#[test]
fn clean_command_alias_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clean", all_enabled_flags()),
Some(SlashCommand::Stop)
);
}
#[test]
fn fast_command_is_hidden_when_disabled() {
let mut flags = all_enabled_flags();
flags.fast_command_enabled = false;
assert_eq!(find_builtin_command("fast", flags), None);
}
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
assert_eq!(find_builtin_command("realtime", flags), None);
}
#[test]
fn settings_command_is_hidden_when_realtime_is_disabled() {
let mut flags = all_enabled_flags();
flags.realtime_conversation_enabled = false;
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
let mut flags = all_enabled_flags();
flags.audio_device_selection_enabled = false;
assert_eq!(find_builtin_command("settings", flags), None);
}
}
@@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Google Calendar
Plan events and schedules.
Plan and reference events from your calendar
Use $ to insert this app into the prompt.
Enable this app to use it for the current request.
Newly installed apps can take a few minutes to appear in /apps.
› 1. Manage on ChatGPT
2. Enable app
3. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
@@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Google Calendar
Plan events and schedules.
Plan and reference events from your calendar
Install this app in your browser, then return here.
Newly installed apps can take a few minutes to appear in /apps.
After installed, use $ to insert this app into the prompt.
› 1. Install on ChatGPT
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close
@@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 120)"
---
Would you like to run the following command?
Reason: need macOS automation
Permission rule: macOS preferences readwrite; macOS automation com.apple.Calendar, com.apple.Notes; macOS
accessibility; macOS calendar; macOS reminders
$ osascript -e 'tell application'
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,17 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 120)"
---
Would you like to run the following command?
Reason: need filesystem access
Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt`
$ cat /tmp/readme.txt
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "render_overlay_lines(&view, 80)"
---
Would you like to run the following command?
Thread: Robie [explorer]
$ echo hi
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel or o to open thread
@@ -0,0 +1,16 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))"
---
Would you like to grant these permissions?
Reason: need workspace access
Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt`
› 1. Yes, grant these permissions (y)
2. Yes, grant these permissions for this session (a)
3. No, continue without permissions (n)
Press enter to confirm or esc to cancel
@@ -0,0 +1,38 @@
---
source: tui/src/bottom_pane/approval_overlay.rs
assertion_line: 821
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 100, height: 12 },
content: [
" ",
" Do you want to approve network access to "example.com"? ",
" ",
" Reason: network request blocked ",
" ",
" ",
"› 1. Yes, just this once (y) ",
" 2. Yes, and allow this host for this conversation (a) ",
" 3. Yes, and allow this host in the future (p) ",
" 4. 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: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 57, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 28, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 54, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 45, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 46, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› [Pasted Content 1002 chars][Pasted Content 1004 chars] "
" "
" "
" "
" "
" "
" "
" "
" 100% context left "
@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anythin "
" "
" "
" "
" "
" "
" "
" 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ? for shortcuts · Plan mode (shift+tab to cycle) 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" Plan mode (shift+tab to cycle) 100% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" Plan mode (shift+tab to cycle) "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anythin "
" "
" "
" "
" "
" "
" "
" Plan mode "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue message · Plan mode 98% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue message · Plan mode "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" Plan mode "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue · Plan mode 98% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue · Plan mode "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue message 98% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue 98% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue message 98% context left "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Test "
" "
" "
" "
" "
" "
" "
" tab to queue message "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to quit "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to quit "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc again to edit previous message "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› h "
" "
" "
" "
" "
" "
" "
" 100% context left "
Loaded 100 of 1109 files, more files were not shown because too many files have changed in this diff. Show more