Skip to content

Commit aeedb28

Browse files
feat(acp): answer bubble shows what was chosen
The collapsed ask_user answer bubble only said "Answered N questions". It now lists one line per question under that header, e.g. "Q2 Controls → radio, textarea · changed", with long answers cut; clicking still expands the full text. Confirm answers are written as "value (label)" like choices so the overview shows the label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 690a4fa commit aeedb28

6 files changed

Lines changed: 131 additions & 8 deletions

File tree

‎src/AskUserForm.cpp‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,8 @@ QString confirmText(const Question &q, bool yes)
564564
const QString label = yes ? q.yesLabel : q.noLabel;
565565
if (label.isEmpty() || label.compare(plain, Qt::CaseInsensitive) == 0)
566566
return plain;
567-
return QStringLiteral("%1 (%2)").arg(label, plain);
567+
// "value (label)", like choices: the overview shows what is in brackets.
568+
return QStringLiteral("%1 (%2)").arg(plain, label);
568569
}
569570

570571
QString quoted(const QString &text)
@@ -1040,6 +1041,75 @@ QJsonObject elicitationContent(const Spec &spec, const QVector<Answer> &answers)
10401041
return content;
10411042
}
10421043

1044+
namespace {
1045+
1046+
// "value (label)" -> "label"; quoted free text -> unquoted.
1047+
QString displayItem(QString item)
1048+
{
1049+
item = item.trimmed();
1050+
if (item.startsWith(QLatin1String("other: ")))
1051+
return QStringLiteral("other: ") + displayItem(item.mid(7));
1052+
if (item.size() >= 2 && item.startsWith(QLatin1Char('"')) && item.endsWith(QLatin1Char('"')))
1053+
return item.mid(1, item.size() - 2);
1054+
if (item.endsWith(QLatin1Char(')'))) {
1055+
const qsizetype open = item.indexOf(QLatin1String(" ("));
1056+
if (open > 0)
1057+
return item.mid(open + 2, item.size() - open - 3);
1058+
}
1059+
return item;
1060+
}
1061+
1062+
} // namespace
1063+
1064+
QStringList answerOverview(const QString &text, int maxAnswerChars)
1065+
{
1066+
QStringList out;
1067+
if (!text.startsWith(QLatin1String(kAnswersHeader)))
1068+
return out;
1069+
static const QLatin1String kRecommended(" (recommended)");
1070+
static const QString kChanged = QStringLiteral(" — changed (recommended: ");
1071+
const QStringList lines = text.split(QLatin1Char('\n'));
1072+
for (int i = 1; i < lines.size(); ++i) {
1073+
const QString &line = lines.at(i);
1074+
if (line.isEmpty() || line.at(0).isSpace() || line.startsWith(QLatin1String("Form: ")))
1075+
continue;
1076+
const qsizetype space = line.indexOf(QLatin1Char(' '));
1077+
const qsizetype titleEnd = line.indexOf(QLatin1String("\": "));
1078+
if (space <= 0 || titleEnd < space || line.at(space + 1) != QLatin1Char('"'))
1079+
continue;
1080+
const QString id = line.left(space);
1081+
const QString title = line.mid(space + 2, titleEnd - space - 2);
1082+
QString answer = line.mid(titleEnd + 3);
1083+
bool changed = false;
1084+
if (answer.endsWith(kRecommended)) {
1085+
answer.chop(kRecommended.size());
1086+
} else if (const qsizetype c = answer.indexOf(kChanged); c >= 0) {
1087+
answer.truncate(c);
1088+
changed = true;
1089+
}
1090+
if (answer.endsWith(QLatin1Char('|'))) {
1091+
// Multi-line text: its first line follows, indented.
1092+
const QString next = i + 1 < lines.size() ? lines.at(i + 1).trimmed() : QString();
1093+
answer.chop(1);
1094+
answer += next;
1095+
}
1096+
QString shown;
1097+
if (answer.startsWith(QLatin1Char('"')) || answer.startsWith(QLatin1String("change requested: "))) {
1098+
shown = answer.startsWith(QLatin1Char('"')) ? displayItem(answer) : answer;
1099+
} else {
1100+
QStringList items = answer.split(QStringLiteral(", "));
1101+
for (QString &item : items)
1102+
item = displayItem(item);
1103+
shown = items.join(QStringLiteral(", "));
1104+
}
1105+
if (maxAnswerChars > 1 && shown.size() > maxAnswerChars)
1106+
shown = shown.left(maxAnswerChars - 1) + QStringLiteral("…");
1107+
out.append(QStringLiteral("%1 %2 → %3").arg(id, title, shown)
1108+
+ (changed ? QStringLiteral(" · changed") : QString()));
1109+
}
1110+
return out;
1111+
}
1112+
10431113
QString dismissPrompt(const QString &note)
10441114
{
10451115
QString out = QLatin1String(kAnswersHeader) + QLatin1Char('\n') + QLatin1String(kDismissedLine);

‎src/AskUserForm.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <QJsonObject>
1616
#include <QJsonValue>
1717
#include <QString>
18+
#include <QStringList>
1819
#include <QVector>
1920

2021
#include <cstdint>
@@ -149,6 +150,9 @@ QString formatAnswers(const Spec &spec, const QVector<Answer> &answers);
149150
QString answerSummary(const Spec &spec, const QVector<Answer> &answers);
150151
// Same summary, recomputed from the prompt text (restored history).
151152
QString summaryFromAnswersText(const QString &text);
153+
// One line per question from the prompt text: "Q1 Title → answer[ · changed]",
154+
// answers cut to `maxAnswerChars`. Empty for dismiss / clarify prompts.
155+
QStringList answerOverview(const QString &text, int maxAnswerChars = 90);
152156

153157
QString dismissPrompt(const QString &note);
154158
QString clarifyPrompt(const Question &q);

‎src/widgets/AcpMessageWidget.cpp‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ void AcpMessageWidget::setContent(const QVector<AcpProtocol::AcpContentBlock> &c
351351
rerender();
352352
}
353353

354-
void AcpMessageWidget::setCollapsedSummary(const QString &summary)
354+
void AcpMessageWidget::setCollapsedSummary(const QString &summary, const QStringList &overview)
355355
{
356356
if (m_role != QLatin1String("user") || !m_layout)
357357
return;
@@ -366,14 +366,25 @@ void AcpMessageWidget::setCollapsedSummary(const QString &summary)
366366
if (m_chatFontSet)
367367
m_summaryHeader->setFont(m_chatFont);
368368
m_layout->insertWidget(0, m_summaryHeader);
369+
m_summaryOverview = new QLabel(this);
370+
m_summaryOverview->setTextFormat(Qt::PlainText);
371+
m_summaryOverview->setWordWrap(true);
372+
m_summaryOverview->setTextInteractionFlags(Qt::TextSelectableByMouse);
373+
if (m_chatFontSet)
374+
m_summaryOverview->setFont(m_chatFont);
375+
m_layout->insertWidget(1, m_summaryOverview);
369376
connect(m_summaryHeader, &QToolButton::toggled, this, [this](bool expanded) {
370377
for (QWidget *w : std::as_const(m_userBlocks))
371378
w->setVisible(expanded);
379+
// Expanded shows the full text; the overview would repeat it.
380+
m_summaryOverview->setVisible(!expanded && !m_summaryOverview->text().isEmpty());
372381
m_summaryHeader->setText(m_summary + (expanded ? QStringLiteral(" ▾") : QStringLiteral(" ▸")));
373382
updateGeometry();
374383
});
375384
}
376385
m_summary = QStringLiteral("↳ ") + summary;
386+
m_summaryOverview->setText(overview.join(QLatin1Char('\n')));
387+
m_summaryOverview->setVisible(!overview.isEmpty());
377388
m_summaryHeader->setChecked(false);
378389
m_summaryHeader->setText(m_summary + QStringLiteral(" ▸"));
379390
for (QWidget *w : std::as_const(m_userBlocks))
@@ -457,6 +468,12 @@ QSize AcpMessageWidget::sizeHint() const
457468
int contentW = 0;
458469
if (m_summaryHeader)
459470
contentW = m_summaryHeader->sizeHint().width();
471+
if (m_summaryOverview && !m_summaryOverview->isHidden()) {
472+
const QFontMetrics fm = m_summaryOverview->fontMetrics();
473+
const QStringList lines = m_summaryOverview->text().split(QLatin1Char('\n'));
474+
for (const QString &line : lines)
475+
contentW = qMax(contentW, fm.horizontalAdvance(line));
476+
}
460477
for (const QWidget *w : m_userBlocks) {
461478
const auto *lbl = qobject_cast<const QLabel *>(w);
462479
if (!lbl || lbl->isHidden())
@@ -694,6 +711,8 @@ void AcpMessageWidget::setChatFont(const QFont &font)
694711
}
695712
if (m_summaryHeader)
696713
m_summaryHeader->setFont(font);
714+
if (m_summaryOverview)
715+
m_summaryOverview->setFont(font);
697716
// User-role bodies are QLabels built lazily in setContent(); stamp the ones
698717
// that exist now (newly-created ones are stamped in setContent()).
699718
for (QWidget *w : m_userBlocks) {

‎src/widgets/AcpMessageWidget.h‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ class AcpMessageWidget : public QFrame
8282
// conversation" buttons at the bottom of the bubble, always visible.
8383
void setResponseActions(bool enabled);
8484

85-
// User role: show one clickable `summary` line instead of the content (set
86-
// after setContent); clicking toggles the full text. Used for ask_user
87-
// answer prompts.
88-
void setCollapsedSummary(const QString &summary);
85+
// User role: show a clickable `summary` line plus one `overview` line per
86+
// answer instead of the content (set after setContent); clicking toggles
87+
// the full text. Used for ask_user answer prompts.
88+
void setCollapsedSummary(const QString &summary, const QStringList &overview = {});
8989

9090
bool isCollapsed() const { return m_collapsed; }
9191
QString role() const { return m_role; }
@@ -145,6 +145,7 @@ class AcpMessageWidget : public QFrame
145145
MarkdownView *m_markdown = nullptr; // == m_browser for the assistant role
146146
QToolButton *m_thoughtHeader = nullptr; // thought role
147147
QToolButton *m_summaryHeader = nullptr; // user role, collapsed summary
148+
QLabel *m_summaryOverview = nullptr; // one line per answer while collapsed
148149
QString m_summary;
149150
QLabel *m_goalBadge = nullptr; // Goal badge on result rows
150151
QVBoxLayout *m_layout = nullptr;

‎src/widgets/AcpSessionView.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2333,7 +2333,8 @@ void AcpSessionView::applyMessageMarker(AcpMessageWidget *w, int messageIndex)
23332333
if (!m_model || messageIndex < 0 || messageIndex >= m_model->messages().size())
23342334
return;
23352335
if (m_model->messages().at(messageIndex).marker == QLatin1String(AskUserForm::kMarkerAnswers))
2336-
w->setCollapsedSummary(AskUserForm::summaryFromAnswersText(w->plainText()));
2336+
w->setCollapsedSummary(AskUserForm::summaryFromAnswersText(w->plainText()),
2337+
AskUserForm::answerOverview(w->plainText()));
23372338
}
23382339

23392340
void AcpSessionView::restoreExpiredForm()

‎tests/test_ask_user_form.cpp‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ private slots:
4444
void unwrapsNestedArguments();
4545
void recognisesOmpDeviceWrite();
4646
void infersRecommendedFromRecommendation();
47+
void overviewListsEachAnswer();
4748
};
4849

4950
void TestAskUserForm::parsesGrillRound()
@@ -179,7 +180,7 @@ void TestAskUserForm::formatsAnswersForTheAgent()
179180
"Q2 \"Controls\": radio, textarea, other: \"slider\" — changed (recommended: radio, checkbox)\n"
180181
" note: keep it small\n"
181182
"Q3 \"Module layout\": change requested: \"move core to lib/\" — changed (recommended: accept)\n"
182-
"Q4 \"Scan inside?\": Skip (no) (recommended)\n"
183+
"Q4 \"Scan inside?\": no (Skip) (recommended)\n"
183184
"Q5 \"Describe it\": no answer\n"
184185
"Q6 \"Days\": 45 — changed (recommended: 30)\n");
185186
QCOMPARE(formatAnswers(spec, answers), expected);
@@ -411,5 +412,32 @@ void TestAskUserForm::infersRecommendedFromRecommendation()
411412
QVERIFY(spec->questions.at(0).recommended.isNull());
412413
}
413414

415+
void TestAskUserForm::overviewListsEachAnswer()
416+
{
417+
const Spec spec = grillSpec();
418+
const QVector<Answer> answers{
419+
answer("Q1", QStringLiteral("b2")),
420+
answer("Q2", QJsonArray{QStringLiteral("radio"), QStringLiteral("textarea")}, "", "keep it small"),
421+
answer("Q3", QStringLiteral("accept")),
422+
answer("Q4", false),
423+
answer("Q5", QStringLiteral("line one\nline two")),
424+
answer("Q6", QJsonValue()),
425+
};
426+
const QStringList lines = answerOverview(formatAnswers(spec, answers), 40);
427+
QCOMPARE(lines, QStringList({
428+
QStringLiteral("Q1 Architecture → No-op tool"),
429+
QStringLiteral("Q2 Controls → radio, textarea · changed"),
430+
QStringLiteral("Q3 Module layout → accepted"),
431+
QStringLiteral("Q4 Scan inside? → Skip"),
432+
QStringLiteral("Q5 Describe it → line one"),
433+
QStringLiteral("Q6 Days → no answer"),
434+
}));
435+
436+
// Long answers are cut; dismiss/clarify prompts have no overview.
437+
const QStringList cut = answerOverview(formatAnswers(spec, {answer("Q5", QString(60, QLatin1Char('x')))}), 20);
438+
QCOMPARE(cut.at(4), QStringLiteral("Q5 Describe it → ") + QString(19, QLatin1Char('x')) + QStringLiteral("…"));
439+
QVERIFY(answerOverview(dismissPrompt(QString())).isEmpty());
440+
}
441+
414442
QTEST_GUILESS_MAIN(TestAskUserForm)
415443
#include "test_ask_user_form.moc"

0 commit comments

Comments
 (0)