| @@ -11,7 +11,7 @@ use serde_json::Value; |
| 11 | 11 | |
| 12 | 12 | use super::{ChatView, Notice}; |
| 13 | 13 | 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}; |
| 15 | 15 | use crate::ui::composer::{self, Density, Tone}; |
| 16 | 16 | use crate::ui::icons::Lucide; |
| 17 | 17 | use crate::ui::metal::{self, MetalButton, Palette}; |
| @@ -69,6 +69,14 @@ pub fn pending_approval(session: &Session) -> Option<(usize, &ToolCall, usize)> |
| 69 | 69 | Some((ix, tool, 1 + pending.count())) |
| 70 | 70 | } |
| 71 | 71 | |
| 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 | + |
| 72 | 80 | /// The plan Claude asks to start on, if ExitPlanMode waits for an answer. |
| 73 | 81 | pub fn pending_plan(session: &Session) -> Option<(usize, &ToolCall)> { |
| 74 | 82 | pending_approval(session).filter(|(_, tool, _)| tool.name == "ExitPlanMode").map(|(ix, tool, _)| (ix, tool)) |
| @@ -128,8 +136,9 @@ impl ChatView { |
| 128 | 136 | let approval = pending_approval(session).map(|(ix, _, count)| (ix, count)); |
| 129 | 137 | let plan = pending_plan(session).map(|(ix, tool)| (ix, plan_title(tool))); |
| 130 | 138 | let ask = session.pending_ask().map(|(ix, _)| ix); |
| 139 | + let extension = pending_extension_question(session).map(|(ix, _)| ix); |
| 131 | 140 | 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(); |
| 133 | 142 | let show_tasks = |
| 134 | 143 | !blocking && !steps.is_empty() && (session.is_working() || steps.iter().any(|step| step.status != StepStatus::Completed)); |
| 135 | 144 | let queued = !session.queue.is_empty(); |
| @@ -147,7 +156,11 @@ impl ChatView { |
| 147 | 156 | (Some((ix, title)), _, _) => items.push(render_plan_ready(ix, title, !items.is_empty(), p)), |
| 148 | 157 | (None, Some((ix, count)), _) => items.push(render_approval(&self.session, ix, count, !items.is_empty(), cx)), |
| 149 | 158 | (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 | + } |
| 151 | 164 | } |
| 152 | 165 | if show_tasks { |
| 153 | 166 | 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 |
| 809 | 822 | .into_any_element() |
| 810 | 823 | } |
| 811 | 824 | |
| 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 | + |
| 812 | 879 | /// Claude's plan, ready to implement: the footer below offers Implement or |
| 813 | 880 | /// Refine. |
| 814 | 881 | fn render_plan_ready(ix: usize, title: String, joined: bool, p: &Palette) -> AnyElement { |