Vectrum
Sign In

Pi extension questions in the composer drawer

Like T3 Code's pending questions, a Pi extension's confirm or choice now
opens above the composer, with its options to click or pick with 1-9, and
the composer says "Choose an option above" until it's answered. The
transcript keeps the question and its answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 8a8cfa7commit53b9675

Showing 2 changed files with 89 additions and 20 deletions.

+19−17src/ui/chat.rsView File
@@ -355,9 +355,12 @@ impl ChatView {
355355 let approval = dock::pending_approval(session).is_some() && dock::pending_plan(session).is_none();
356356 let ask = session.pending_ask().map(|(ix, ask)| (ix, ask.current, ask.custom[ask.current].clone()));
357357 let plan = dock::pending_plan(session).is_some();
358+ let choice_only = dock::pending_extension_question(session).is_some() && ask.is_none();
358359 let no_provider = Catalog::global(cx).enabled().next().is_none();
359360 let placeholder = if approval {
360361 "Resolve this approval request to continue"
362+ } else if choice_only {
363+ "Choose an option above"
361364 } else if ask.is_some() {
362365 "Type your own answer, or leave this blank to use the selected option"
363366 } else if plan {
@@ -371,7 +374,7 @@ impl ChatView {
371374 self.placeholder = placeholder;
372375 self.composer.update(cx, |composer, cx| {
373376 composer.set_placeholder(placeholder, window, cx);
374- composer.set_disabled(approval, cx);
377+ composer.set_disabled(approval || choice_only, cx);
375378 });
376379 }
377380 // A question shown anew brings its own typed answer into the composer.
@@ -629,6 +632,15 @@ impl ChatView {
629632 let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else {
630633 return;
631634 };
635+ if let Some((ix, question)) = dock::pending_extension_question(self.session.read(cx))
636+ && self.session.read(cx).pending_ask().is_none()
637+ {
638+ if digit <= question.options.len() {
639+ self.session.update(cx, |session, cx| session.answer_question(ix, digit - 1, cx));
640+ cx.stop_propagation();
641+ }
642+ return;
643+ }
632644 let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return };
633645 if self.question_collapsed == Some((ix, ask.current)) || digit > ask.questions[ask.current].options.len() {
634646 return;
@@ -1831,7 +1843,7 @@ fn render_turn_summary(
18311843 }
18321844
18331845 /// A question from an agent extension, answered with one click.
1834- fn render_question(session: &Entity<Session>, ix: usize, question: &Question, p: &Palette) -> AnyElement {
1846+ fn render_question(question: &Question, p: &Palette) -> AnyElement {
18351847 v_flex()
18361848 .w_full()
18371849 .gap(px(10.))
@@ -1861,20 +1873,10 @@ fn render_question(session: &Entity<Session>, ix: usize, question: &Question, p:
18611873 .when_some(question.message.clone(), |this, message| {
18621874 this.child(div().text_size(px(13.)).text_color(p.ink2).child(message))
18631875 })
1864- .map(|this| match &question.answer {
1865- Some(answer) => this.child(metal::engraved(format!("Answered: {answer}"), p)),
1866- None => this.child(h_flex().flex_wrap().gap(px(6.)).children(question.options.iter().enumerate().map(
1867- |(option, label)| {
1868- let session = session.clone();
1869- MetalButton::new(ElementId::NamedInteger(format!("question-{ix}").into(), option as u64))
1870- .compact()
1871- .when(option == 0 && question.confirm, |this| this.primary())
1872- .label(label.clone())
1873- .on_click(move |_, _, cx| {
1874- session.update(cx, |session, cx| session.answer_question(ix, option, cx))
1875- })
1876- },
1877- ))),
1876+ .child(match &question.answer {
1877+ Some(answer) => metal::engraved(format!("Answered: {answer}"), p),
1878+ // Answered in the drawer above the composer.
1879+ None => metal::engraved("Waiting for your answer", p).text_color(p.amber_ink),
18781880 })
18791881 .into_any_element()
18801882 }
@@ -2124,7 +2126,7 @@ fn render_item(
21242126 .child(markdown(&answer.view, p))
21252127 .into_any_element(),
21262128 ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx),
2127- ChatItem::Question(question) => render_question(session, ix, question, p),
2129+ ChatItem::Question(question) => render_question(question, p),
21282130 ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p),
21292131 ChatItem::Notice(NoticeKind::Info, text) => h_flex()
21302132 .items_start()
+70−3src/ui/chat/dock.rsView File
@@ -11,7 +11,7 @@ use serde_json::Value;
1111
1212 use super::{ChatView, Notice};
1313 use crate::clock;
14- use crate::session::{ChatItem, PermissionDecision, PermissionState, Session, ToolCall, UserQuestions, tool_field};
14+ use crate::session::{ChatItem, PermissionDecision, PermissionState, Question, Session, ToolCall, UserQuestions, tool_field};
1515 use crate::ui::composer::{self, Density, Tone};
1616 use crate::ui::icons::Lucide;
1717 use crate::ui::metal::{self, MetalButton, Palette};
@@ -69,6 +69,14 @@ pub fn pending_approval(session: &Session) -> Option<(usize, &ToolCall, usize)>
6969 Some((ix, tool, 1 + pending.count()))
7070 }
7171
72+ /// A question from a Pi extension waiting for an answer.
73+ pub fn pending_extension_question(session: &Session) -> Option<(usize, &Question)> {
74+ session.items.iter().enumerate().find_map(|(ix, item)| match item {
75+ ChatItem::Question(question) if question.answer.is_none() => Some((ix, question)),
76+ _ => None,
77+ })
78+ }
79+
7280 /// The plan Claude asks to start on, if ExitPlanMode waits for an answer.
7381 pub fn pending_plan(session: &Session) -> Option<(usize, &ToolCall)> {
7482 pending_approval(session).filter(|(_, tool, _)| tool.name == "ExitPlanMode").map(|(ix, tool, _)| (ix, tool))
@@ -128,8 +136,9 @@ impl ChatView {
128136 let approval = pending_approval(session).map(|(ix, _, count)| (ix, count));
129137 let plan = pending_plan(session).map(|(ix, tool)| (ix, plan_title(tool)));
130138 let ask = session.pending_ask().map(|(ix, _)| ix);
139+ let extension = pending_extension_question(session).map(|(ix, _)| ix);
131140 let steps = task_steps(session);
132- let blocking = approval.is_some() || ask.is_some();
141+ let blocking = approval.is_some() || ask.is_some() || extension.is_some();
133142 let show_tasks =
134143 !blocking && !steps.is_empty() && (session.is_working() || steps.iter().any(|step| step.status != StepStatus::Completed));
135144 let queued = !session.queue.is_empty();
@@ -147,7 +156,11 @@ impl ChatView {
147156 (Some((ix, title)), _, _) => items.push(render_plan_ready(ix, title, !items.is_empty(), p)),
148157 (None, Some((ix, count)), _) => items.push(render_approval(&self.session, ix, count, !items.is_empty(), cx)),
149158 (None, None, Some(ix)) => items.push(self.render_question(ix, !items.is_empty(), cx)),
150- _ => {}
159+ (None, None, None) => {
160+ if let Some(ix) = extension {
161+ items.push(render_extension_question(&self.session, ix, !items.is_empty(), cx));
162+ }
163+ }
151164 }
152165 if show_tasks {
153166 items.push(self.render_tasks(steps, !items.is_empty(), cx));
@@ -809,6 +822,60 @@ fn render_approval(session: &Entity<Session>, ix: usize, count: usize, joined: b
809822 .into_any_element()
810823 }
811824
825+ /// A Pi extension's question: its title and message, and its options to
826+ /// pick with a click or 1–9.
827+ fn render_extension_question(session: &Entity<Session>, ix: usize, joined: bool, cx: &App) -> AnyElement {
828+ let p = metal::palette(cx);
829+ let Some(ChatItem::Question(question)) = session.read(cx).items.get(ix) else { return div().into_any_element() };
830+ let options = question.options.iter().enumerate().map(|(option, label)| {
831+ let session = session.clone();
832+ h_flex()
833+ .id(("extension-option", option))
834+ .w_full()
835+ .gap(px(8.))
836+ .px(px(10.))
837+ .py(px(8.))
838+ .rounded(px(8.))
839+ .cursor_pointer()
840+ .text_color(p.ink.opacity(0.85))
841+ .hover(|style| style.bg(p.row_hover))
842+ .on_click(move |_, _, cx| session.update(cx, |session, cx| session.answer_question(ix, option, cx)))
843+ .child(div().flex_1().min_w_0().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(label.clone()))
844+ .when(option < 9, |this| {
845+ this.child(
846+ div()
847+ .flex()
848+ .flex_none()
849+ .items_center()
850+ .justify_center()
851+ .size(px(20.))
852+ .text_size(px(10.))
853+ .font_weight(FontWeight::MEDIUM)
854+ .text_color(p.ink3)
855+ .child((option + 1).to_string()),
856+ )
857+ })
858+ });
859+ composer::banner(Tone::Neutral, Density::Default, joined, p)
860+ .child(
861+ composer::row()
862+ .child(composer::icon_cell(None::<Div>, p))
863+ .child(composer::content().child(div().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Question"))),
864+ )
865+ .child(
866+ v_flex()
867+ .pl(px(28.))
868+ .pr(px(4.))
869+ .pb(px(4.))
870+ .child(div().text_size(px(14.)).text_color(p.ink.opacity(0.85)).child(question.title.clone()))
871+ .when_some(question.message.clone(), |this, message| {
872+ this.child(div().mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(message))
873+ })
874+ .child(v_flex().mt(px(8.)).gap(px(2.)).children(options)),
875+ )
876+ .into_any_element()
877+ }
878+
812879 /// Claude's plan, ready to implement: the footer below offers Implement or
813880 /// Refine.
814881 fn render_plan_ready(ix: usize, title: String, joined: bool, p: &Palette) -> AnyElement {