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}