Skip to content
Open
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
1 change: 1 addition & 0 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50668,6 +50668,7 @@ mod tests {
})
.collect(),
is_secret: false,
allow_other: false,
};
let answered = crate::acp::question::QuestionOutcome {
answers: vec![crate::acp::question::QuestionAnsweredItem {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/acp/cursor_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ pub fn parse_cursor_ask_questions(params: &Value) -> Result<Vec<CursorAskQuestio
multi_select,
options,
is_secret: false,
allow_other: true,
},
cursor_id,
option_ids,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/acp/delegation/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3395,6 +3395,7 @@ mod tests {
},
],
is_secret: false,
allow_other: true,
}],
})
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/acp/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10643,6 +10643,7 @@ mod tests {
},
],
is_secret: false,
allow_other: true,
}]
}

Expand Down
22 changes: 22 additions & 0 deletions src-tauri/src/acp/question.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ pub struct QuestionOption {
pub description: String,
}

fn default_true() -> bool {
true
}

/// A single multiple-choice question.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuestionSpec {
Expand All @@ -91,6 +95,11 @@ pub struct QuestionSpec {
/// input. Default false — absent on the wire for every non-secret source.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_secret: bool,
/// When false, the question card hides the "Other" option and free-text input.
/// Defaults to true; false for native permission bridges (Antigravity, Pi select)
/// where only predetermined option IDs can be submitted.
#[serde(default = "default_true")]
pub allow_other: bool,
}

/// The pending (awaiting-answer) question set stored on
Expand Down Expand Up @@ -310,6 +319,7 @@ pub fn parse_questions(arguments: &Value) -> Result<Vec<QuestionSpec>, String> {
multi_select,
options,
is_secret: false,
allow_other: true,
});
}
Ok(out)
Expand Down Expand Up @@ -567,6 +577,7 @@ pub fn parse_grok_ext_questions(params: &Value) -> Result<Vec<QuestionSpec>, Str
multi_select,
options,
is_secret: false,
allow_other: true,
});
}
Ok(out)
Expand Down Expand Up @@ -675,6 +686,7 @@ pub fn parse_pi_select_ask(tool_call: &Value, options: &[(String, String)]) -> O
multi_select: false,
options: choices,
is_secret: false,
allow_other: false,
},
option_ids,
})
Expand Down Expand Up @@ -803,6 +815,7 @@ pub fn parse_antigravity_ask(
multi_select: false,
options: choices,
is_secret: false,
allow_other: false,
},
option_ids,
})
Expand Down Expand Up @@ -1827,6 +1840,7 @@ fn parse_form_questions(
multi_select,
options,
is_secret: is_secret_property(raw, id),
allow_other: true,
});
fields.push(ElicitationField {
other_field_id: minimax_companions.get(id).cloned(),
Expand Down Expand Up @@ -2038,6 +2052,7 @@ pub fn grok_result_card_input(specs: &[QuestionSpec]) -> Value {
"question": s.question,
"multiSelect": s.multi_select,
"options": s.options,
"allowOther": s.allow_other,
})
})
.collect();
Expand Down Expand Up @@ -2128,6 +2143,7 @@ mod tests {
assert!(!qs[0].multi_select);
assert_eq!(qs[0].options.len(), 2);
assert!(!qs[0].id.is_empty());
assert!(qs[0].allow_other);
}

fn elicitation_raw(props: Value, required: Value) -> Value {
Expand Down Expand Up @@ -3554,6 +3570,7 @@ mod tests {
})
.collect(),
is_secret: false,
allow_other: true,
};

assert!(validate_specs(&[]).is_err(), "empty set");
Expand Down Expand Up @@ -3597,6 +3614,7 @@ mod tests {
},
],
is_secret: false,
allow_other: true,
};
assert!(validate_specs(&[blank_id]).is_err(), "blank id");
// Duplicate option label within one question (parse_questions rejects it).
Expand All @@ -3616,6 +3634,7 @@ mod tests {
},
],
is_secret: false,
allow_other: true,
};
assert!(
validate_specs(&[dup_label]).is_err(),
Expand Down Expand Up @@ -3935,6 +3954,7 @@ mod tests {
);
// `ctx.ui.select` yields one value.
assert!(!ask.spec.multi_select);
assert!(!ask.spec.allow_other);
assert_eq!(ask.spec.options.len(), 4);
assert_eq!(
ask.spec.options[0].label,
Expand Down Expand Up @@ -4254,13 +4274,15 @@ mod tests {
.expect("实测交互格式应转换为问题卡片");
assert_eq!(ask.spec.options[1].label, "前端开发");
assert!(!ask.spec.multi_select);
assert!(!ask.spec.allow_other);
assert_eq!(ask.option_ids[1].1, "opaque-second");

let mut no_kind = antigravity_interaction();
no_kind.as_object_mut().unwrap().remove("kind");
let ask_no_kind = parse_antigravity_ask(&no_kind, &antigravity_choices())
.expect("实测 antigravity-acp 1.4.1 省略 kind 字段亦应正确识别为问题卡片");
assert_eq!(ask_no_kind.spec.options[1].label, "前端开发");
assert!(!ask_no_kind.spec.allow_other);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/chat_channel/event_subscriber.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ mod permission_push_tests {
},
],
is_secret: false,
allow_other: true,
}],
},
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/chat_channel/message_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ mod question_request_tests {
})
.collect(),
is_secret: false,
allow_other: true,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/tests/delegation_e2e_uds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ async fn end_to_end_uds_ask_question_round_trip() {
},
],
is_secret: false,
allow_other: true,
}],
};
client_ask_round_trip(&socket_str, &req).await
Expand Down Expand Up @@ -697,6 +698,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() {
},
],
is_secret: false,
allow_other: true,
}],
};
let resp = client_ask_round_trip(&socket.to_string_lossy(), &req)
Expand Down
55 changes: 55 additions & 0 deletions src/components/chat/ask-question-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,61 @@ describe("AskQuestionCard", () => {
expect(screen.queryByRole("radio")).not.toBeInTheDocument()
})

it("hides the Other option when allow_other is false in single-select", () => {
renderCard({
question_id: "q-no-other-single",
created_at: "2026-01-01T00:00:00Z",
questions: [
{
id: "qa",
question: "Which approach?",
header: "Approach",
multi_select: false,
allow_other: false,
options: [
{ label: "Incremental", description: "smaller diffs" },
{ label: "Rewrite", description: "clean slate" },
],
},
],
})
expect(screen.getAllByRole("radio")).toHaveLength(2)
expect(
screen.queryByRole("radio", { name: "Other" })
).not.toBeInTheDocument()
expect(
screen.queryByPlaceholderText("Type your answer?")
).not.toBeInTheDocument()
})

it("hides the Other option when allow_other is false in multi-select", () => {
renderCard({
question_id: "q-no-other-multi",
created_at: "2026-01-01T00:00:00Z",
questions: [
{
id: "qb",
question: "Which modules?",
header: "Scope",
multi_select: true,
allow_other: false,
options: [
{ label: "auth", description: "" },
{ label: "billing", description: "" },
{ label: "ui", description: "" },
],
},
],
})
expect(screen.getAllByRole("checkbox")).toHaveLength(3)
expect(
screen.queryByRole("checkbox", { name: "Other" })
).not.toBeInTheDocument()
expect(
screen.queryByPlaceholderText("Type your answer?")
).not.toBeInTheDocument()
})

it("submits the typed Other text as the answer label", () => {
const onAnswer = renderCard(single)
fireEvent.click(screen.getByRole("radio", { name: "Other" }))
Expand Down
53 changes: 31 additions & 22 deletions src/components/chat/ask-question-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ export function AskQuestionCard({
// tallest question (its option count plus the always-present "Other" row),
// capped to the viewport; a taller-than-estimated tab scrolls internally.
const maxRows = useMemo(
() => questions.reduce((m, q) => Math.max(m, q.options.length + 1), 1),
() =>
questions.reduce(
(m, q) =>
Math.max(m, q.options.length + (q.allow_other === false ? 0 : 1)),
1
),
[questions]
)
const bodyHeight = `min(${maxRows * 4 + 1}rem, 50svh)`
Expand Down Expand Up @@ -377,16 +382,18 @@ export function AskQuestionCard({
</Label>
)
})}
<Label className={cardClass(s?.otherActive ?? false)}>
<Checkbox
checked={s?.otherActive ?? false}
disabled={locked}
onCheckedChange={() => toggleOther(q)}
className="mt-0.5"
/>
<span className="text-sm font-medium">{t("other")}</span>
</Label>
{otherInput}
{q.allow_other !== false && (
<Label className={cardClass(s?.otherActive ?? false)}>
<Checkbox
checked={s?.otherActive ?? false}
disabled={locked}
onCheckedChange={() => toggleOther(q)}
className="mt-0.5"
/>
<span className="text-sm font-medium">{t("other")}</span>
</Label>
)}
{q.allow_other !== false && otherInput}
</div>
)
}
Expand Down Expand Up @@ -423,18 +430,20 @@ export function AskQuestionCard({
</Label>
)
})}
<Label className={cardClass(s?.otherActive ?? false)}>
<RadioGroupItem
value={OTHER_VALUE}
onClick={() => {
if (s?.otherActive) toggleOther(q)
}}
className="mt-0.5 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
/>
<span className="text-sm font-medium">{t("other")}</span>
</Label>
{q.allow_other !== false && (
<Label className={cardClass(s?.otherActive ?? false)}>
<RadioGroupItem
value={OTHER_VALUE}
onClick={() => {
if (s?.otherActive) toggleOther(q)
}}
className="mt-0.5 data-[state=checked]:border-primary data-[state=checked]:bg-primary"
/>
<span className="text-sm font-medium">{t("other")}</span>
</Label>
)}
</RadioGroup>
{otherInput}
{q.allow_other !== false && otherInput}
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/components/message/ask-question-result-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function AskQuestionResultCard({
multi_select: q.multiSelect,
options: q.options,
is_secret: q.isSecret,
allow_other: q.allowOther,
})),
}
}, [questions])
Expand Down
7 changes: 7 additions & 0 deletions src/lib/ask-question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface AskQuestion {
* answer envelope is keyed by that id rather than the question text (unlike
* the codeg-mcp ask, which the card matches by header+question). */
id?: string
allowOther?: boolean
}

export interface AskQuestionAnswer {
Expand Down Expand Up @@ -146,6 +147,12 @@ export function parseAskQuestionInput(
options,
isSecret: obj.isSecret === true || obj.is_secret === true,
id: typeof obj.id === "string" && obj.id ? obj.id : undefined,
allowOther:
obj.allowOther === false || obj.allow_other === false
? false
: obj.allowOther === true || obj.allow_other === true
? true
: undefined,
})
}
return out
Expand Down
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,7 @@ export interface QuestionSpec {
multi_select: boolean
options: QuestionOption[]
is_secret?: boolean
allow_other?: boolean
}

/** Awaiting-answer question set on the session (mirror of `PendingQuestionState`). */
Expand Down
Loading