Skip to main content

cosh_tools/question/
types.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4
5/// Virtual option the TUI appends to every single-select question so the
6/// user can always give a free-text answer. Never sent by the model — the
7/// tool description explicitly tells it NOT to invent its own custom/other
8/// entry, and [`crate::question::Question::validate_and_normalize`] rejects
9/// payloads whose options collide with this reserved label (the TUI renders
10/// it as a separate row, so a colliding option would be unreachable).
11pub const CUSTOM_RESPONSE_LABEL: &str = "Personalize your response";
12
13/// Marker suffix a model appends to an option label to mark its
14/// recommendation (the market convention: recommended option first, with
15/// this suffix). Stripped by [`QuestionInput::finalize`]; the stripped label
16/// becomes the internal [`QuestionItem::recommended`] badge.
17pub const RECOMMENDED_SUFFIX: &str = "(Recommended)";
18
19/// The kind of answer a question renders as, derived from the wire shape:
20/// `multiSelect: true` → multi-select; any non-empty `options` → single
21/// select; otherwise free text. Internal — never part of the model-facing
22/// schema.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
24pub enum QuestionType {
25    /// Free-text input. The user types their answer.
26    #[default]
27    Text,
28    /// Single selection from a list of options (radio buttons).
29    SingleChoice,
30    /// Multiple selection from a list of options (checkboxes).
31    MultiChoice,
32}
33
34impl QuestionType {
35    /// `true` when this kind renders a choice list (`options` must exist).
36    pub fn is_choice(self) -> bool {
37        matches!(self, Self::SingleChoice | Self::MultiChoice)
38    }
39}
40
41/// One choice inside a question's `options` list.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
43pub struct QuestionOption {
44    /// Display text the user will see and select. Concise (1-5 words).
45    pub label: String,
46    /// Explanation of what this option means or what will happen if chosen.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub description: Option<String>,
49}
50
51impl QuestionOption {
52    /// Wrap a bare label.
53    pub fn from_label(label: impl Into<String>) -> Self {
54        Self {
55            label: label.into(),
56            description: None,
57        }
58    }
59}
60
61/// A single question to ask the user.
62///
63/// Wire format — the industry-standard shape used by Claude Code's
64/// `AskUserQuestion` and its ports (Qwen Code, OpenCode, Crush):
65///
66/// ```json
67/// {"question": "...", "header": "...",
68///  "options": [{"label": "...", "description": "..."}], "multiSelect": false}
69/// ```
70///
71/// The kind is derived, never declared: `multiSelect` selects the multi
72/// kind, any non-empty `options` the single kind, absence of options the
73/// free-text kind. `question_type` and `id` are internal (serialized for
74/// the TUI and answer correlation, excluded from the model-facing schema).
75#[derive(Debug, Clone, Serialize, JsonSchema)]
76pub struct QuestionItem {
77    /// The complete question text displayed to the user. Clear, specific,
78    /// ends with a question mark.
79    pub question: String,
80    /// Very short label displayed as a chip/tag (max 12 chars). Examples:
81    /// "Auth method", "Library", "Approach".
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub header: Option<String>,
84    /// The available choices for this question (2-4 options). Omit for a
85    /// free-text question. Do NOT add an "Other" option — free text is
86    /// always offered automatically.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub options: Option<Vec<QuestionOption>>,
89    /// Set to `true` to allow the user to select multiple options instead
90    /// of just one. Defaults to `false`.
91    #[serde(
92        rename = "multiSelect",
93        default,
94        skip_serializing_if = "std::ops::Not::not"
95    )]
96    pub multi_select: bool,
97    /// Why the agent needs this information. Helps the user understand the
98    /// context and give better answers.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub purpose: Option<String>,
101    /// Whether an answer is required. Defaults to `true`.
102    #[serde(default = "default_required")]
103    pub required: bool,
104    /// Internal: derived kind, per the struct doc. Never part of the schema.
105    #[serde(default)]
106    #[schemars(skip)]
107    pub question_type: QuestionType,
108    /// Internal: stable identifier used to correlate answers. Derived from
109    /// position when absent. Never part of the schema.
110    #[serde(default, skip_serializing_if = "String::is_empty")]
111    #[schemars(skip)]
112    pub id: String,
113    /// Internal: label of the recommended single-select option (marked via
114    /// [`RECOMMENDED_SUFFIX`], moved to the top by the tool). Never part of
115    /// the schema.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    #[schemars(skip)]
118    pub recommended: Option<String>,
119}
120
121impl Default for QuestionItem {
122    fn default() -> Self {
123        Self {
124            question: String::new(),
125            header: None,
126            options: None,
127            multi_select: false,
128            purpose: None,
129            required: true,
130            question_type: QuestionType::Text,
131            id: String::new(),
132            recommended: None,
133        }
134    }
135}
136
137impl QuestionItem {
138    /// Free-text constructor (no options).
139    pub fn text(question: impl Into<String>) -> Self {
140        Self {
141            question: question.into(),
142            ..Self::default()
143        }
144    }
145
146    /// Single-select constructor from plain labels.
147    pub fn single_choice(question: impl Into<String>, labels: &[&str]) -> Self {
148        Self {
149            question: question.into(),
150            options: Some(
151                labels
152                    .iter()
153                    .map(|l| QuestionOption::from_label(*l))
154                    .collect(),
155            ),
156            question_type: QuestionType::SingleChoice,
157            ..Self::default()
158        }
159    }
160
161    /// Multi-select constructor from plain labels.
162    pub fn multi_choice(question: impl Into<String>, labels: &[&str]) -> Self {
163        Self {
164            question: question.into(),
165            options: Some(
166                labels
167                    .iter()
168                    .map(|l| QuestionOption::from_label(*l))
169                    .collect(),
170            ),
171            question_type: QuestionType::MultiChoice,
172            multi_select: true,
173            ..Self::default()
174        }
175    }
176}
177
178const fn default_required() -> bool {
179    true
180}
181
182/// Lenient wire decoder for one question item.
183///
184/// Accepts the canonical shape plus the emission variants models produce
185/// regardless of the schema they were shown:
186///
187/// - `options` as plain strings instead of `{label}` objects
188/// - `options` as a JSON-encoded string (double-encoded emission)
189/// - `multiple` as an alias of `multiSelect`
190/// - an item with only `header` and no `question` (the header carries the
191///   prompt — same relaxation OpenCode shipped for its question tool)
192///
193/// Returns `None` when there is neither a `question` nor a `header` to show.
194fn question_item_from_wire(value: &JsonValue) -> Option<QuestionItem> {
195    let obj = value.as_object()?;
196    let string_field = |key: &str| {
197        obj.get(key)
198            .and_then(JsonValue::as_str)
199            .map(str::to_string)
200            .filter(|s| !s.is_empty())
201    };
202
203    let question = string_field("question").or_else(|| string_field("header"))?;
204    let header = string_field("header");
205
206    let multi_select = obj
207        .get("multiSelect")
208        .or_else(|| obj.get("multiple"))
209        .and_then(JsonValue::as_bool)
210        .unwrap_or(false);
211    let options = decode_options(obj.get("options"));
212
213    Some(QuestionItem {
214        question,
215        header,
216        options,
217        multi_select,
218        purpose: string_field("purpose"),
219        required: obj
220            .get("required")
221            .and_then(JsonValue::as_bool)
222            .unwrap_or(true),
223        question_type: QuestionType::Text,
224        id: String::new(),
225        recommended: None,
226    })
227}
228
229/// Decode `options` accepting canonical `[{label, description}]`, bare
230/// `[string]`, and a JSON-encoded string of either. `None` when the field is
231/// absent or not a list.
232fn decode_options(value: Option<&JsonValue>) -> Option<Vec<QuestionOption>> {
233    let arr: &Vec<JsonValue> = match value? {
234        JsonValue::Array(arr) => arr,
235        // Models sometimes double-encode the whole array as a string.
236        JsonValue::String(s) => match serde_json::from_str::<Vec<JsonValue>>(s) {
237            Ok(parsed) => {
238                return Some(parsed.iter().filter_map(decode_option_item).collect());
239            }
240            Err(_) => return None,
241        },
242        _ => return None,
243    };
244    Some(arr.iter().filter_map(decode_option_item).collect())
245}
246
247/// Decode one option entry: a bare string or a `{label, description}` object.
248/// Blank labels are rejected — they would render as empty selectable rows.
249fn decode_option_item(item: &JsonValue) -> Option<QuestionOption> {
250    match item {
251        JsonValue::String(label) => {
252            if label.trim().is_empty() {
253                None
254            } else {
255                Some(QuestionOption::from_label(label.clone()))
256            }
257        }
258        JsonValue::Object(_) => serde_json::from_value(item.clone())
259            .ok()
260            .filter(|o: &QuestionOption| !o.label.trim().is_empty()),
261        _ => None,
262    }
263}
264
265impl<'de> serde::de::Deserialize<'de> for QuestionItem {
266    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
267    where
268        D: serde::de::Deserializer<'de>,
269    {
270        let value = <JsonValue as serde::de::Deserialize>::deserialize(deserializer)?;
271        question_item_from_wire(&value).ok_or_else(|| {
272            serde::de::Error::custom(
273                "each question needs a non-empty `question` (or `header`) field",
274            )
275        })
276    }
277}
278
279/// A single answer from the user.
280#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
281pub struct AnswerItem {
282    /// The question ID this answer corresponds to.
283    pub id: String,
284    /// The answer text (for free-text questions).
285    pub answer: Option<String>,
286    /// Selected option(s) (for choice questions).
287    pub selected: Option<Vec<String>>,
288}
289
290/// Input for `ask_questions`.
291///
292/// Decodes through the lenient per-item decoder ([`question_item_from_wire`])
293/// so canonical shapes and common model emission variants parse into the
294/// same canonical struct.
295#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
296pub struct QuestionInput {
297    /// Questions to ask the user (1-4 per call). Ask ALL questions you need
298    /// in a single call to minimize back-and-forth.
299    pub questions: Vec<QuestionItem>,
300}
301
302impl QuestionInput {
303    /// Internal: derive the canonical fields the wire leaves implicit — the
304    /// per-question kind (from `multiSelect`/options presence), stable ids
305    /// (from position, never colliding with ids already present), and the
306    /// recommendation (from a [`RECOMMENDED_SUFFIX`] label, wherever it
307    /// appears — models often place it on a non-first option).
308    pub(crate) fn finalize(&mut self) {
309        let mut used: std::collections::HashSet<String> = self
310            .questions
311            .iter()
312            .filter(|q| !q.id.is_empty())
313            .map(|q| q.id.clone())
314            .collect();
315        for (idx, q) in self.questions.iter_mut().enumerate() {
316            if q.id.is_empty() {
317                let mut candidate = format!("q{idx}");
318                let mut n = 1;
319                while used.contains(&candidate) {
320                    candidate = format!("q{idx}-{n}");
321                    n += 1;
322                }
323                used.insert(candidate.clone());
324                q.id = candidate;
325            }
326
327            if q.multi_select {
328                q.question_type = QuestionType::MultiChoice;
329            } else if q.options.as_ref().is_some_and(|o| !o.is_empty()) {
330                q.question_type = QuestionType::SingleChoice;
331            } else {
332                q.question_type = QuestionType::Text;
333            }
334
335            if let Some(opts) = &mut q.options {
336                let mut found: Option<String> = None;
337                for opt in opts.iter_mut() {
338                    if let Some(stripped) = opt
339                        .label
340                        .strip_suffix(RECOMMENDED_SUFFIX)
341                        .map(str::trim_end)
342                        .filter(|l| !l.is_empty())
343                    {
344                        opt.label = stripped.to_string();
345                        found.get_or_insert_with(|| opt.label.clone());
346                    }
347                }
348                // A recommendation only means something on single-select;
349                // multi-select just gets the cleaned label.
350                if q.recommended.is_none() && q.question_type == QuestionType::SingleChoice {
351                    q.recommended = found;
352                }
353            }
354        }
355    }
356}
357
358/// Output from `ask_questions`. Contains the user's answers.
359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
360pub struct QuestionOutput {
361    /// The questions that were asked (echoed back so the TUI can render them).
362    pub questions: Vec<QuestionItem>,
363    /// The user's answers, in the same order as the questions.
364    /// Empty when the tool is first called — the TUI captures answers and
365    /// returns them on a subsequent invocation.
366    pub answers: Vec<AnswerItem>,
367}