Compare commits

...

1 Commits

Author SHA1 Message Date
Charles Cunningham
c261d45ece Ask question tool 2026-01-19 23:42:13 -08:00
23 changed files with 1052 additions and 0 deletions

View File

@@ -86,6 +86,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::oneshot;
use tracing::error;
use tracing::warn;
type JsonValue = serde_json::Value;
@@ -255,6 +256,18 @@ pub(crate) async fn apply_bespoke_event_handling(
});
}
},
EventMsg::AskUserQuestionRequest(ev) => {
// Auto-resolve to avoid blocking the session in app-server mode.
if let Err(err) = conversation
.submit(Op::ResolveAskUserQuestion {
id: ev.id,
answers: Vec::new(),
})
.await
{
warn!("failed to auto-resolve ask-user-question request: {err}");
}
}
// TODO(celia): properly construct McpToolCall TurnItem in core.
EventMsg::McpToolCallBegin(begin_event) => {
let notification = construct_mcp_tool_call_notification(

View File

@@ -34,9 +34,11 @@ use async_channel::Receiver;
use async_channel::Sender;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::ask_user_question::AskUserQuestion;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::AskUserQuestionRequestEvent;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::ItemCompletedEvent;
@@ -1257,6 +1259,37 @@ impl Session {
rx_approve
}
pub async fn request_user_question(
&self,
turn_context: &TurnContext,
id: String,
question: AskUserQuestion,
) -> Vec<String> {
let sub_id = turn_context.sub_id.clone();
let (tx_answer, rx_answer) = oneshot::channel();
let prev_entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_question_answer(id.clone(), tx_answer)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending question for id: {id}");
}
let event = EventMsg::AskUserQuestionRequest(AskUserQuestionRequestEvent {
id: id.clone(),
turn_id: sub_id,
question,
});
self.send_event(turn_context, event).await;
rx_answer.await.unwrap_or_default()
}
pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -1278,6 +1311,27 @@ impl Session {
}
}
pub async fn notify_user_question(&self, id: &str, answers: Vec<String>) {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_question_answer(id)
}
None => None,
}
};
match entry {
Some(tx_answer) => {
tx_answer.send(answers).ok();
}
None => {
warn!("No pending question found for id: {id}");
}
}
}
pub async fn resolve_elicitation(
&self,
server_name: String,
@@ -1896,6 +1950,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
} => {
handlers::resolve_elicitation(&sess, server_name, request_id, decision).await;
}
Op::ResolveAskUserQuestion { id, answers } => {
handlers::resolve_ask_user_question(&sess, id, answers).await;
}
Op::Shutdown => {
if handlers::shutdown(&sess, sub.id.clone()).await {
break;
@@ -2091,6 +2148,10 @@ mod handlers {
}
}
pub async fn resolve_ask_user_question(sess: &Arc<Session>, id: String, answers: Vec<String>) {
sess.notify_user_question(&id, answers).await;
}
/// Propagate a user's exec approval decision to the session.
/// Also optionally applies an execpolicy amendment.
pub async fn exec_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {

View File

@@ -98,6 +98,8 @@ pub enum Feature {
EnableRequestCompression,
/// Enable collab tools.
Collab,
/// Allow the agent to ask interactive questions.
AskUserQuestion,
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
Steer,
}
@@ -418,6 +420,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::AskUserQuestion,
key: "ask_user_question",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Tui2,
key: "tui2",

View File

@@ -69,6 +69,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ExecApprovalRequest(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::AskUserQuestionRequest(_)
| EventMsg::BackgroundEvent(_)
| EventMsg::StreamError(_)
| EventMsg::PatchApplyBegin(_)

View File

@@ -67,6 +67,7 @@ impl ActiveTurn {
#[derive(Default)]
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_question_answers: HashMap<String, oneshot::Sender<Vec<String>>>,
pending_input: Vec<ResponseInputItem>,
}
@@ -79,6 +80,14 @@ impl TurnState {
self.pending_approvals.insert(key, tx)
}
pub(crate) fn insert_pending_question_answer(
&mut self,
key: String,
tx: oneshot::Sender<Vec<String>>,
) -> Option<oneshot::Sender<Vec<String>>> {
self.pending_question_answers.insert(key, tx)
}
pub(crate) fn remove_pending_approval(
&mut self,
key: &str,
@@ -86,8 +95,16 @@ impl TurnState {
self.pending_approvals.remove(key)
}
pub(crate) fn remove_pending_question_answer(
&mut self,
key: &str,
) -> Option<oneshot::Sender<Vec<String>>> {
self.pending_question_answers.remove(key)
}
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_question_answers.clear();
self.pending_input.clear();
}

View File

@@ -0,0 +1,75 @@
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::ask_user_question::AskUserQuestion;
pub struct AskUserQuestionHandler;
#[derive(Deserialize)]
struct AskUserQuestionArgs {
questions: Vec<AskUserQuestion>,
}
#[async_trait]
impl ToolHandler for AskUserQuestionHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
call_id,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"ask_user_question handler received unsupported payload".to_string(),
));
}
};
let args: AskUserQuestionArgs = parse_arguments(&arguments)?;
if args.questions.is_empty() {
return Err(FunctionCallError::RespondToModel(
"ask_user_question requires at least one question".to_string(),
));
}
let mut answers: Vec<Vec<String>> = Vec::with_capacity(args.questions.len());
let mut question_ids: Vec<String> = Vec::with_capacity(args.questions.len());
for (idx, question) in args.questions.iter().cloned().enumerate() {
let id = format!("{call_id}:{idx}");
let selected = session
.request_user_question(turn.as_ref(), id.clone(), question.clone())
.await;
question_ids.push(id);
answers.push(selected);
}
let payload = json!({
"questions": args.questions,
"question_ids": question_ids,
"answers": answers,
});
Ok(ToolOutput::Function {
content: payload.to_string(),
content_items: None,
success: Some(true),
})
}
}

View File

@@ -1,4 +1,5 @@
pub mod apply_patch;
mod ask_user_question;
pub(crate) mod collab;
mod grep_files;
mod list_dir;
@@ -16,6 +17,7 @@ use serde::Deserialize;
use crate::function_tool::FunctionCallError;
pub use apply_patch::ApplyPatchHandler;
pub use ask_user_question::AskUserQuestionHandler;
pub use collab::CollabHandler;
pub use grep_files::GrepFilesHandler;
pub use list_dir::ListDirHandler;

View File

@@ -26,6 +26,7 @@ pub(crate) struct ToolsConfig {
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: WebSearchMode,
pub collab_tools: bool,
pub ask_user_question: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -44,6 +45,7 @@ impl ToolsConfig {
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_collab_tools = features.enabled(Feature::Collab);
let include_ask_user_question = features.enabled(Feature::AskUserQuestion);
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
@@ -75,6 +77,7 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_mode: *web_search_mode,
collab_tools: include_collab_tools,
ask_user_question: include_ask_user_question,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
}
}
@@ -1105,12 +1108,85 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
fn create_ask_user_question_tool() -> ToolSpec {
let option_properties = BTreeMap::from([
(
"label".to_string(),
JsonSchema::String {
description: Some("Short option label.".to_string()),
},
),
(
"description".to_string(),
JsonSchema::String {
description: Some("Brief description of the option.".to_string()),
},
),
]);
let question_properties = BTreeMap::from([
(
"question".to_string(),
JsonSchema::String {
description: Some("Question to ask the user.".to_string()),
},
),
(
"header".to_string(),
JsonSchema::String {
description: Some("Optional short header for display.".to_string()),
},
),
(
"options".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: option_properties,
required: Some(vec!["label".to_string(), "description".to_string()]),
additional_properties: Some(false.into()),
}),
description: Some("Selectable options.".to_string()),
},
),
(
"multiSelect".to_string(),
JsonSchema::Boolean {
description: Some("Whether multiple selections are allowed.".to_string()),
},
),
]);
let properties = BTreeMap::from([(
"questions".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: question_properties,
required: Some(vec!["question".to_string(), "options".to_string()]),
additional_properties: Some(false.into()),
}),
description: Some("Questions to ask, in order.".to_string()),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "ask_user_question".to_string(),
description: "Ask the user clarifying questions and return their answers.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
})
}
/// Builds the tool registry builder while collecting tool specs for later serialization.
pub(crate) fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::AskUserQuestionHandler;
use crate::tools::handlers::CollabHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::ListDirHandler;
@@ -1132,6 +1208,7 @@ pub(crate) fn build_specs(
let plan_handler = Arc::new(PlanHandler);
let apply_patch_handler = Arc::new(ApplyPatchHandler);
let view_image_handler = Arc::new(ViewImageHandler);
let ask_user_question_handler = Arc::new(AskUserQuestionHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
@@ -1241,6 +1318,11 @@ pub(crate) fn build_specs(
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
builder.register_handler("view_image", view_image_handler);
if config.ask_user_question {
builder.push_spec(create_ask_user_question_tool());
builder.register_handler("ask_user_question", ask_user_question_handler);
}
if config.collab_tools {
let collab_handler = Arc::new(CollabHandler);
builder.push_spec(create_spawn_agent_tool());

View File

@@ -249,6 +249,19 @@ impl EventProcessor for EventProcessorWithHumanOutput {
"auto-cancelling (not supported in exec mode)".style(self.dimmed)
);
}
EventMsg::AskUserQuestionRequest(ev) => {
ts_msg!(
self,
"{} {}",
"question requested".style(self.magenta),
ev.question.question.style(self.dimmed)
);
ts_msg!(
self,
"{}",
"auto-skipping (not supported in exec mode)".style(self.dimmed)
);
}
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
let last_message = last_agent_message.as_deref();
if let Some(output_file) = self.last_message_path.as_deref() {

View File

@@ -104,6 +104,7 @@ impl EventProcessorWithJsonOutput {
protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev),
protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev),
protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev),
protocol::EventMsg::AskUserQuestionRequest(_) => Vec::new(),
protocol::EventMsg::WebSearchBegin(_) => Vec::new(),
protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev),
protocol::EventMsg::TokenCount(ev) => {

View File

@@ -479,6 +479,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
})
.await?;
}
if let EventMsg::AskUserQuestionRequest(ev) = &event.msg {
// Automatically skip user questions in exec mode.
thread
.submit(Op::ResolveAskUserQuestion {
id: ev.id.clone(),
answers: Vec::new(),
})
.await?;
}
if matches!(event.msg, EventMsg::Error(_)) {
error_seen = true;
}

View File

@@ -29,6 +29,7 @@ use mcp_types::RequestId;
use mcp_types::TextContent;
use serde_json::json;
use tokio::sync::Mutex;
use tracing::warn;
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
@@ -262,6 +263,19 @@ async fn run_codex_tool_session_inner(
// TODO: forward elicitation requests to the client?
continue;
}
EventMsg::AskUserQuestionRequest(ev) => {
// Auto-resolve to prevent tool calls from hanging in MCP mode.
if let Err(err) = thread
.submit(Op::ResolveAskUserQuestion {
id: ev.id,
answers: Vec::new(),
})
.await
{
warn!("failed to auto-resolve ask-user-question request: {err}");
}
continue;
}
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
turn_id: _,

View File

@@ -0,0 +1,28 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct AskUserQuestionOption {
pub label: String,
pub description: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct AskUserQuestion {
pub question: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub header: Option<String>,
pub options: Vec<AskUserQuestionOption>,
#[serde(rename = "multiSelect", default)]
pub multi_select: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct AskUserQuestionRequestEvent {
pub id: String,
#[serde(default)]
pub turn_id: String,
pub question: AskUserQuestion,
}

View File

@@ -1,4 +1,5 @@
pub mod account;
pub mod ask_user_question;
mod thread_id;
#[allow(deprecated)]
pub use thread_id::ConversationId;

View File

@@ -43,6 +43,9 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent;
pub use crate::approvals::ElicitationAction;
pub use crate::approvals::ExecApprovalRequestEvent;
pub use crate::approvals::ExecPolicyAmendment;
pub use crate::ask_user_question::AskUserQuestion;
pub use crate::ask_user_question::AskUserQuestionOption;
pub use crate::ask_user_question::AskUserQuestionRequestEvent;
/// Open/close tags for special user-input blocks. Used across crates to avoid
/// duplicated hardcoded strings.
@@ -178,6 +181,14 @@ pub enum Op {
decision: ElicitationAction,
},
/// Resolve an AskUserQuestion request from the agent.
ResolveAskUserQuestion {
/// The id of the submission we are answering.
id: String,
/// Selected option label(s), in order.
answers: Vec<String>,
},
/// Append an entry to the persistent cross-session message history.
///
/// Note the entry is not guaranteed to be logged if the user has
@@ -714,6 +725,8 @@ pub enum EventMsg {
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
AskUserQuestionRequest(AskUserQuestionRequestEvent),
/// Notification advising the user that something they are using has been
/// deprecated and should be phased out.
DeprecationNotice(DeprecationNoticeEvent),

View File

@@ -0,0 +1,324 @@
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 crate::app_event::AppEvent;
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::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint;
use crate::render::renderable::Renderable;
use codex_core::protocol::AskUserQuestion;
use codex_core::protocol::Op;
use textwrap::wrap;
pub(crate) struct AskUserQuestionView {
id: String,
question: AskUserQuestion,
selections: Vec<bool>,
state: ScrollState,
done: bool,
app_event_tx: AppEventSender,
}
impl AskUserQuestionView {
pub(crate) fn new(id: String, question: AskUserQuestion, app_event_tx: AppEventSender) -> Self {
let mut state = ScrollState::new();
state.clamp_selection(question.options.len());
state.ensure_visible(question.options.len(), MAX_POPUP_ROWS);
Self {
id,
selections: vec![false; question.options.len()],
question,
state,
done: false,
app_event_tx,
}
}
fn move_up(&mut self) {
let len = self.question.options.len();
self.state.move_up_wrap(len);
self.state
.ensure_visible(len, MAX_POPUP_ROWS.min(len.max(1)));
}
fn move_down(&mut self) {
let len = self.question.options.len();
self.state.move_down_wrap(len);
self.state
.ensure_visible(len, MAX_POPUP_ROWS.min(len.max(1)));
}
fn toggle_selected(&mut self, idx: usize) {
if let Some(selected) = self.selections.get_mut(idx) {
*selected = !*selected;
}
}
fn submit(&mut self, answers: Vec<String>) {
self.app_event_tx
.send(AppEvent::CodexOp(Op::ResolveAskUserQuestion {
id: self.id.clone(),
answers,
}));
self.done = true;
}
fn submit_single(&mut self, idx: Option<usize>) {
let answers = idx
.and_then(|idx| self.question.options.get(idx))
.map(|opt| vec![opt.label.clone()])
.unwrap_or_default();
self.submit(answers);
}
fn submit_multi(&mut self) {
let answers = self
.question
.options
.iter()
.zip(self.selections.iter())
.filter_map(|(opt, selected)| selected.then(|| opt.label.clone()))
.collect();
self.submit(answers);
}
fn header_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(header) = self.question.header.clone() {
lines.push(Line::from(header.cyan().bold()));
}
let question_text = format!("Question: {}", self.question.question);
for line in wrap(&question_text, width.max(1) as usize) {
lines.push(Line::from(line.to_string().bold()));
}
lines.push(Line::from(""));
lines
}
fn footer_hint(&self) -> Line<'static> {
if self.question.multi_select {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to submit or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".into(),
])
} else {
standard_popup_hint_line()
}
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.question
.options
.iter()
.enumerate()
.map(|(idx, opt)| {
let name = if self.question.multi_select {
let marker = if *self.selections.get(idx).unwrap_or(&false) {
"[x]"
} else {
"[ ]"
};
format!("{marker} {}. {}", idx + 1, opt.label)
} else {
format!("{}. {}", idx + 1, opt.label)
};
GenericDisplayRow {
name,
description: Some(opt.description.clone()),
..Default::default()
}
})
.collect()
}
}
impl BottomPaneView for AskUserQuestionView {
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::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,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} if self.question.multi_select => {
if let Some(idx) = self.state.selected_idx {
self.toggle_selected(idx);
}
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if modifiers.is_empty() => {
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
&& idx < self.question.options.len()
{
if self.question.multi_select {
self.toggle_selected(idx);
} else {
self.submit_single(Some(idx));
}
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if self.question.multi_select {
self.submit_multi();
} else {
self.submit_single(self.state.selected_idx);
}
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.submit(Vec::new());
}
_ => {}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.done {
return CancellationEvent::Handled;
}
self.submit(Vec::new());
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
}
impl Renderable for AskUserQuestionView {
fn desired_height(&self, width: u16) -> u16 {
let header_lines = self.header_lines(width);
let rows = self.build_rows();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
let footer_height = 1u16;
header_lines.len() as u16 + rows_height + footer_height
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let header_lines = self.header_lines(area.width);
let header_height = header_lines.len() as u16;
let footer_height = 1u16;
let available_list_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let mut y = area.y;
for line in header_lines {
if y >= area.y + area.height {
break;
}
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
if available_list_height > 0 {
let list_area = Rect {
x: area.x,
y,
width: area.width,
height: available_list_height,
};
let rows = self.build_rows();
render_rows(
list_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"No options",
);
}
if area.height >= footer_height {
let footer_area = Rect {
x: area.x,
y: area.y + area.height - footer_height,
width: area.width,
height: footer_height,
};
let line = self.footer_hint();
line.render(footer_area, buf);
}
}
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
}

View File

@@ -37,6 +37,7 @@ use std::time::Duration;
mod approval_overlay;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
mod ask_user_question_view;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
@@ -94,6 +95,7 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use ask_user_question_view::AskUserQuestionView;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;

View File

@@ -47,6 +47,7 @@ use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::AgentReasoningRawContentDeltaEvent;
use codex_core::protocol::AgentReasoningRawContentEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskUserQuestionRequestEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::CreditsSnapshot;
use codex_core::protocol::DeprecationNoticeEvent;
@@ -120,6 +121,7 @@ use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::AskUserQuestionView;
use crate::bottom_pane::BetaFeatureItem;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
@@ -1036,6 +1038,14 @@ impl ChatWidget {
);
}
fn on_ask_user_question_request(&mut self, ev: AskUserQuestionRequestEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_ask_user_question(ev),
|s| s.handle_ask_user_question_request_now(ev2),
);
}
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
if is_unified_exec_source(ev.source) {
@@ -1476,6 +1486,14 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn handle_ask_user_question_request_now(&mut self, ev: AskUserQuestionRequestEvent) {
self.flush_answer_stream_with_separator();
let view = AskUserQuestionView::new(ev.id, ev.question, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.running_commands.insert(
@@ -2400,6 +2418,9 @@ impl ChatWidget {
EventMsg::ElicitationRequest(ev) => {
self.on_elicitation_request(ev);
}
EventMsg::AskUserQuestionRequest(ev) => {
self.on_ask_user_question_request(ev);
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),

View File

@@ -1,6 +1,7 @@
use std::collections::VecDeque;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskUserQuestionRequestEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
@@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
Elicitation(ElicitationRequestEvent),
AskUserQuestion(AskUserQuestionRequestEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
McpBegin(McpToolCallBeginEvent),
@@ -57,6 +59,10 @@ impl InterruptManager {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
pub(crate) fn push_ask_user_question(&mut self, ev: AskUserQuestionRequestEvent) {
self.queue.push_back(QueuedInterrupt::AskUserQuestion(ev));
}
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
}
@@ -85,6 +91,9 @@ impl InterruptManager {
chat.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::AskUserQuestion(ev) => {
chat.handle_ask_user_question_request_now(ev)
}
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),

View File

@@ -0,0 +1,326 @@
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 crate::app_event::AppEvent;
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::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint;
use crate::render::renderable::Renderable;
use codex_core::protocol::AskUserQuestion;
use codex_core::protocol::Op;
use textwrap::wrap;
pub(crate) struct AskUserQuestionView {
id: String,
question: AskUserQuestion,
selections: Vec<bool>,
state: ScrollState,
done: bool,
app_event_tx: AppEventSender,
}
impl AskUserQuestionView {
pub(crate) fn new(id: String, question: AskUserQuestion, app_event_tx: AppEventSender) -> Self {
let mut state = ScrollState::new();
state.clamp_selection(question.options.len());
state.ensure_visible(question.options.len(), MAX_POPUP_ROWS);
Self {
id,
selections: vec![false; question.options.len()],
question,
state,
done: false,
app_event_tx,
}
}
fn move_up(&mut self) {
let len = self.question.options.len();
self.state.move_up_wrap(len);
self.state
.ensure_visible(len, MAX_POPUP_ROWS.min(len.max(1)));
}
fn move_down(&mut self) {
let len = self.question.options.len();
self.state.move_down_wrap(len);
self.state
.ensure_visible(len, MAX_POPUP_ROWS.min(len.max(1)));
}
fn toggle_selected(&mut self, idx: usize) {
if let Some(selected) = self.selections.get_mut(idx) {
*selected = !*selected;
}
}
fn submit(&mut self, answers: Vec<String>) {
self.app_event_tx
.send(AppEvent::CodexOp(Op::ResolveAskUserQuestion {
id: self.id.clone(),
answers,
}));
self.done = true;
}
fn submit_single(&mut self, idx: Option<usize>) {
let answers = idx
.and_then(|idx| self.question.options.get(idx))
.map(|opt| vec![opt.label.clone()])
.unwrap_or_default();
self.submit(answers);
}
fn submit_multi(&mut self) {
let answers = self
.question
.options
.iter()
.zip(self.selections.iter())
.filter_map(|(opt, selected)| selected.then(|| opt.label.clone()))
.collect();
self.submit(answers);
}
fn header_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(header) = self.question.header.clone() {
lines.push(Line::from(header.cyan().bold()));
}
let question_text = format!("Question: {}", self.question.question);
for line in wrap(&question_text, width.max(1) as usize) {
lines.push(Line::from(line.to_string().bold()));
}
lines.push(Line::from(""));
lines
}
fn footer_hint(&self) -> Line<'static> {
if self.question.multi_select {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to submit or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to cancel".into(),
])
} else {
standard_popup_hint_line()
}
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.question
.options
.iter()
.enumerate()
.map(|(idx, opt)| {
let name = if self.question.multi_select {
let marker = if *self.selections.get(idx).unwrap_or(&false) {
"[x]"
} else {
"[ ]"
};
format!("{marker} {}. {}", idx + 1, opt.label)
} else {
format!("{}. {}", idx + 1, opt.label)
};
GenericDisplayRow {
name,
description: Some(opt.description.clone()),
display_shortcut: None,
match_indices: None,
wrap_indent: None,
}
})
.collect()
}
}
impl BottomPaneView for AskUserQuestionView {
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::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,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
..
} if self.question.multi_select => {
if let Some(idx) = self.state.selected_idx {
self.toggle_selected(idx);
}
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if modifiers.is_empty() => {
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
&& idx < self.question.options.len()
{
if self.question.multi_select {
self.toggle_selected(idx);
} else {
self.submit_single(Some(idx));
}
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
if self.question.multi_select {
self.submit_multi();
} else {
self.submit_single(self.state.selected_idx);
}
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.submit(Vec::new());
}
_ => {}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.done {
return CancellationEvent::Handled;
}
self.submit(Vec::new());
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
}
impl Renderable for AskUserQuestionView {
fn desired_height(&self, width: u16) -> u16 {
let header_lines = self.header_lines(width);
let rows = self.build_rows();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
let footer_height = 1u16;
header_lines.len() as u16 + rows_height + footer_height
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let header_lines = self.header_lines(area.width);
let header_height = header_lines.len() as u16;
let footer_height = 1u16;
let available_list_height = area
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let mut y = area.y;
for line in header_lines {
if y >= area.y + area.height {
break;
}
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
if available_list_height > 0 {
let list_area = Rect {
x: area.x,
y,
width: area.width,
height: available_list_height,
};
let rows = self.build_rows();
render_rows(
list_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"No options",
);
}
if area.height >= footer_height {
let footer_area = Rect {
x: area.x,
y: area.y + area.height - footer_height,
width: area.width,
height: footer_height,
};
let line = self.footer_hint();
line.render(footer_area, buf);
}
}
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
}

View File

@@ -36,6 +36,7 @@ use std::time::Duration;
mod approval_overlay;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
mod ask_user_question_view;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
@@ -57,6 +58,7 @@ mod queued_user_messages;
mod scroll_state;
mod selection_popup_common;
mod textarea;
pub(crate) use ask_user_question_view::AskUserQuestionView;
pub(crate) use feedback_view::FeedbackNoteView;
/// How long the "press again to quit" hint stays visible.

View File

@@ -45,6 +45,7 @@ use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::AgentReasoningRawContentDeltaEvent;
use codex_core::protocol::AgentReasoningRawContentEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskUserQuestionRequestEvent;
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::CreditsSnapshot;
use codex_core::protocol::DeprecationNoticeEvent;
@@ -116,6 +117,7 @@ use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event::WindowsSandboxFallbackReason;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::AskUserQuestionView;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
@@ -948,6 +950,14 @@ impl ChatWidget {
);
}
fn on_ask_user_question_request(&mut self, ev: AskUserQuestionRequestEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_ask_user_question(ev),
|s| s.handle_ask_user_question_request_now(ev2),
);
}
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
let ev2 = ev.clone();
@@ -1289,6 +1299,14 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn handle_ask_user_question_request_now(&mut self, ev: AskUserQuestionRequestEvent) {
self.flush_answer_stream_with_separator();
let view = AskUserQuestionView::new(ev.id, ev.question, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Ensure the status indicator is visible while the command runs.
self.running_commands.insert(
@@ -2150,6 +2168,9 @@ impl ChatWidget {
EventMsg::ElicitationRequest(ev) => {
self.on_elicitation_request(ev);
}
EventMsg::AskUserQuestionRequest(ev) => {
self.on_ask_user_question_request(ev);
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),

View File

@@ -1,6 +1,7 @@
use std::collections::VecDeque;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskUserQuestionRequestEvent;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
@@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt {
ExecApproval(String, ExecApprovalRequestEvent),
ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent),
Elicitation(ElicitationRequestEvent),
AskUserQuestion(AskUserQuestionRequestEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
McpBegin(McpToolCallBeginEvent),
@@ -57,6 +59,10 @@ impl InterruptManager {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
pub(crate) fn push_ask_user_question(&mut self, ev: AskUserQuestionRequestEvent) {
self.queue.push_back(QueuedInterrupt::AskUserQuestion(ev));
}
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
}
@@ -85,6 +91,9 @@ impl InterruptManager {
chat.handle_apply_patch_approval_now(id, ev)
}
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::AskUserQuestion(ev) => {
chat.handle_ask_user_question_request_now(ev)
}
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),