mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
1 Commits
codex-fix/
...
ask-questi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c261d45ece |
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
75
codex-rs/core/src/tools/handlers/ask_user_question.rs
Normal file
75
codex-rs/core/src/tools/handlers/ask_user_question.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: _,
|
||||
|
||||
28
codex-rs/protocol/src/ask_user_question.rs
Normal file
28
codex-rs/protocol/src/ask_user_question.rs
Normal 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,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod account;
|
||||
pub mod ask_user_question;
|
||||
mod thread_id;
|
||||
#[allow(deprecated)]
|
||||
pub use thread_id::ConversationId;
|
||||
|
||||
@@ -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),
|
||||
|
||||
324
codex-rs/tui/src/bottom_pane/ask_user_question_view.rs
Normal file
324
codex-rs/tui/src/bottom_pane/ask_user_question_view.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
326
codex-rs/tui2/src/bottom_pane/ask_user_question_view.rs
Normal file
326
codex-rs/tui2/src/bottom_pane/ask_user_question_view.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user