Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 202 additions & 30 deletions crates/ui/src/composer/components/user_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ impl Composer {
self.ui_question_index = 0;
self.ui_selections.clear();
self.ui_dismissed_request_id = None;
self.ui_async_expanded = false;
let prefill = current
.as_ref()
.and_then(|pending| pending.questions.first())
Expand All @@ -45,9 +46,11 @@ impl Composer {
pending: &PendingUserInput,
cx: &mut Context<Self>,
) -> AnyElement {
if !pending.delivery.is_blocking() {
return self.render_async_question(pending, cx);
}
let request_id = pending.request_id.clone();
let questions = pending.questions.clone();
let blocking = pending.delivery.is_blocking();
let muted = cx.theme().muted_foreground;
let primary = cx.theme().primary;
let total = questions.len();
Expand All @@ -62,12 +65,6 @@ impl Composer {
.cloned()
.unwrap_or_default();

// Codex's non-blocking questions carry no header.
let header_text = if question.header.is_empty() {
crate::tr!("userinput.async_header").into_owned()
} else {
question.header.clone()
};
let header = h_flex()
.w_full()
.gap_2()
Expand All @@ -77,28 +74,14 @@ impl Composer {
.flex_1()
.text_size(px(13.))
.font_medium()
.child(header_text),
.child(question.header.clone()),
)
.when(total > 1, |this| {
this.child(div().text_size(px(11.)).text_color(muted).child(crate::tr!(
"userinput.question_count",
index = index + 1,
total = total
)))
})
.when(!blocking, |this| {
let request_dismiss = request_id.clone();
this.child(
Button::new("ui-dismiss")
.ghost()
.xsmall()
.icon(IconName::Close)
.tooltip(crate::tr!("userinput.dismiss"))
.on_click(cx.listener(move |this, _, _, cx| {
this.ui_dismissed_request_id = Some(request_dismiss.clone());
cx.notify();
})),
)
});

let mut options_content = v_flex().w_full().gap_1();
Expand Down Expand Up @@ -331,14 +314,6 @@ impl Composer {
.shadow_sm()
.child(header)
.child(div().text_size(px(13.)).child(question.question.clone()))
.when(!blocking, |this| {
this.child(
div()
.text_size(px(11.))
.text_color(muted)
.child(crate::tr!("userinput.async_hint")),
)
})
.child(options)
.child(custom_answer)
.when(multi, |this| {
Expand All @@ -354,6 +329,203 @@ impl Composer {
.into_any_element()
}

/// A non-blocking question: the agent keeps working, so it never takes
/// the composer's place. It arrives as a one-line strip and opens into a
/// light inline answer area on demand, as Codex's own clients do.
fn render_async_question(
&self,
pending: &PendingUserInput,
cx: &mut Context<Self>,
) -> AnyElement {
let request_id = pending.request_id.clone();
let questions = pending.questions.clone();
let total = questions.len();
let index = self.ui_question_index.min(total.saturating_sub(1));
let Some(question) = questions.get(index).cloned() else {
return div().into_any_element();
};
let muted = cx.theme().muted_foreground;
let expanded = self.ui_async_expanded;
let touch = if self.compact { 44. } else { 24. };

let toggle = Button::new("ui-async-toggle")
.ghost()
.xsmall()
.when(self.compact, |button| {
button.min_w(px(touch)).min_h(px(touch))
})
.icon(if expanded {
IconName::ChevronDown
} else {
IconName::ChevronUp
})
.tooltip(if expanded {
crate::tr!("userinput.collapse")
} else {
crate::tr!("userinput.answer")
})
.on_click(cx.listener(|this, _, _, cx| {
this.ui_async_expanded = !this.ui_async_expanded;
cx.notify();
}));
let request_dismiss = request_id.clone();
let dismiss = Button::new("ui-dismiss")
.ghost()
.xsmall()
.when(self.compact, |button| {
button.min_w(px(touch)).min_h(px(touch))
})
.icon(IconName::Close)
.tooltip(crate::tr!("userinput.dismiss"))
.on_click(cx.listener(move |this, _, _, cx| {
this.ui_dismissed_request_id = Some(request_dismiss.clone());
cx.notify();
}));

let strip = h_flex()
.id("ui-async-strip")
.w_full()
.gap_2()
.items_center()
.cursor_pointer()
.child(
Icon::new(IconName::Info)
.small()
.text_color(cx.theme().primary),
)
.child(
div()
.flex_none()
.text_size(px(12.))
.font_medium()
.text_color(cx.theme().primary)
.child(crate::tr!("userinput.async_header")),
)
.when(!expanded, |strip| {
strip.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(13.))
.child(question.question.clone()),
)
})
.when(expanded, |strip| strip.child(div().flex_1()))
.when(total > 1, |strip| {
strip.child(
div()
.flex_none()
.text_size(px(11.))
.text_color(muted)
.child(crate::tr!(
"userinput.question_count",
index = index + 1,
total = total
)),
)
})
.child(toggle)
.child(dismiss)
.on_click(cx.listener(|this, _, _, cx| {
this.ui_async_expanded = !this.ui_async_expanded;
cx.notify();
}));

let container = v_flex()
.w_full()
.gap_2()
.px_3()
.py_1p5()
.rounded(crate::material::radius_card())
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().secondary)
.child(strip);
if !expanded {
return container.into_any_element();
}

let selected = self
.ui_selections
.get(&question.id)
.cloned()
.unwrap_or_default();
let chips = h_flex().w_full().flex_wrap().gap_1p5().children(
question
.options
.iter()
.enumerate()
.map(|(opt_index, option)| {
let label = option.label.clone();
let question_for_click = question.clone();
let questions_for_click = questions.clone();
let request_for_click = request_id.clone();
let is_selected = selected.iter().any(|l| l == &option.label);
Button::new(("ui-async-opt", opt_index))
.small()
.when(is_selected, |button| button.primary())
.when(!is_selected, |button| button.outline())
.when(self.compact, |button| button.min_h(px(touch)))
.rounded(crate::material::radius_chip())
.label(option.label.clone())
.on_click(cx.listener(move |this, _, window, cx| {
this.ui_toggle_option(&question_for_click, label.clone(), cx);
this.ui_advance_or_submit(
&questions_for_click,
request_for_click.clone(),
window,
cx,
);
}))
}),
);
let custom_input = self.user_input_custom.clone();
let custom_has_text = !custom_input.read(cx).value().trim().is_empty();
let custom_answer = h_flex()
.w_full()
.items_center()
.gap_2()
.pl_2()
.rounded(crate::material::radius_input())
.border_1()
.border_color(cx.theme().input)
.bg(cx.theme().background)
.child(
div().flex_1().min_w_0().child(
Textarea::new(&self.user_input_custom)
.appearance(false)
.text_size(px(13.)),
),
)
.child(
Button::new("ui-async-custom-submit")
.ghost()
.xsmall()
.when(self.compact, |button| {
button.min_w(px(touch)).min_h(px(touch))
})
.icon(IconName::ArrowUp)
.disabled(!custom_has_text)
.tooltip(crate::tr!("userinput.submit_custom"))
.on_click(cx.listener(move |this, _, window, cx| {
this.submit_custom_user_input(&custom_input, window, cx);
})),
);

container
.child(div().text_size(px(13.)).child(question.question.clone()))
.when(!question.options.is_empty(), |this| this.child(chips))
.child(custom_answer)
.child(
div()
.text_size(px(11.))
.text_color(muted)
.child(crate::tr!("userinput.async_hint")),
)
.into_any_element()
}

/// Number keys 1-9 pressed in the (empty) main composer input select the
/// matching option of the pending blocking question. Returns true when
/// consumed. Deliberately NOT wired to the panel itself: the only focusable
Expand Down
4 changes: 4 additions & 0 deletions crates/ui/src/composer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ pub struct Composer {
/// hidden until a newer request replaces it. The agent keeps working and
/// the question text remains in the transcript.
ui_dismissed_request_id: Option<String>,
/// Whether the non-blocking question strip is open for answering. It
/// arrives closed so it never takes focus from the composer.
ui_async_expanded: bool,
/// The placeholder text last applied to the input (so it is only re-set —
/// which notifies — when it actually changes).
applied_placeholder: String,
Expand Down Expand Up @@ -411,6 +414,7 @@ impl Composer {
ui_question_index: 0,
ui_selections: std::collections::HashMap::new(),
ui_dismissed_request_id: None,
ui_async_expanded: false,
applied_placeholder: crate::tr!("composer.placeholder").into_owned(),
model_picker_token: 0,
control_width: Rc::new(Cell::new(None)),
Expand Down
4 changes: 3 additions & 1 deletion locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,10 @@ userinput:
next_question: "Next question"
done: "Done"
async_header: "Question from the agent"
async_hint: "The agent keeps working. Answer whenever you like, or keep chatting."
async_hint: "The agent keeps working. Answer when ready, or keep chatting."
dismiss: "Hide"
answer: "Answer"
collapse: "Collapse"
fallback:
model_changed_title: "Turn stopped after a model change"
reason_model_changed: "The responding model differs from the selected model, %{model}. Tcode stopped the turn."
Expand Down
2 changes: 2 additions & 0 deletions locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,8 @@ userinput:
async_header: "来自代理的问题"
async_hint: "代理会继续工作。你可以随时回答,也可以继续对话。"
dismiss: "隐藏"
answer: "回答"
collapse: "收起"
fallback:
model_changed_title: "本轮因模型更改而中止"
reason_model_changed: "实际响应的模型与所选模型 %{model} 不同,Tcode 已中止本轮。"
Expand Down