Skip to content

Commit 78d2a4d

Browse files
feat(acp): add a note box at the end of ask_user forms
The user can add anything a question did not ask. It is sent after the answers as "Note from the user: ..." (also with Dismiss), shown in the collapsed answers bubble, carried to a form that supersedes an unanswered one, and hidden for elicitation forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0de5b31 commit 78d2a4d

7 files changed

Lines changed: 187 additions & 18 deletions

File tree

‎src/AskUserForm.cpp‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -698,18 +698,31 @@ QJsonValue fitToQuestion(const Question &q, const QJsonValue &v)
698698
return {};
699699
}
700700

701-
QString summaryText(int answered, int total, int changed, int clarify)
701+
QString summaryText(int answered, int total, int changed, int clarify, bool hasNote)
702702
{
703703
const QString head = answered == total
704704
? QStringLiteral("Answered %1 question%2").arg(total).arg(total == 1 ? QString() : QStringLiteral("s"))
705705
: QStringLiteral("Answered %1 of %2 questions").arg(answered).arg(total);
706706
QString out = head + QStringLiteral(" · %1 changed").arg(changed);
707707
if (clarify > 0)
708708
out += QStringLiteral(" · %1 to clarify").arg(clarify);
709+
if (hasNote)
710+
out += QStringLiteral(" · note");
709711
return out;
710712
}
711713

712714
constexpr QLatin1String kNeedsClarification("needs clarification");
715+
// Never a question line: those start `<id> "`.
716+
constexpr QLatin1String kUserNoteLead("Note from the user: ");
717+
718+
// The form note as a prompt line; continuation lines are indented so the
719+
// summary and overview parsers never read them as questions.
720+
QString userNoteLine(const QString &note)
721+
{
722+
QString text = note.trimmed();
723+
text.replace(QLatin1Char('\n'), QStringLiteral("\n "));
724+
return kUserNoteLead + text;
725+
}
713726

714727
} // namespace
715728

@@ -767,7 +780,7 @@ QVector<Answer> carryOver(const Spec &next, const QVector<Answer> &previous)
767780
return out;
768781
}
769782

770-
QString formatAnswers(const Spec &spec, const QVector<Answer> &answers)
783+
QString formatAnswers(const Spec &spec, const QVector<Answer> &answers, const QString &note)
771784
{
772785
QString out = QLatin1String(kAnswersHeader) + QLatin1Char('\n');
773786
if (!spec.title.isEmpty())
@@ -801,6 +814,8 @@ QString formatAnswers(const Spec &spec, const QVector<Answer> &answers)
801814
out += QStringLiteral(" note: ") + indented + QLatin1Char('\n');
802815
}
803816
}
817+
if (!note.trimmed().isEmpty())
818+
out += userNoteLine(note) + QLatin1Char('\n');
804819
// No `": ` in this line: the summary and overview parsers skip it.
805820
if (!unclear.isEmpty()) {
806821
out += QStringLiteral("To clarify: %1. Explain them in the user's language, then call ask_user again "
@@ -810,7 +825,7 @@ QString formatAnswers(const Spec &spec, const QVector<Answer> &answers)
810825
return out;
811826
}
812827

813-
QString answerSummary(const Spec &spec, const QVector<Answer> &answers)
828+
QString answerSummary(const Spec &spec, const QVector<Answer> &answers, const QString &note)
814829
{
815830
int answered = 0;
816831
int changed = 0;
@@ -827,7 +842,8 @@ QString answerSummary(const Spec &spec, const QVector<Answer> &answers)
827842
if (isChanged(q, *a))
828843
++changed;
829844
}
830-
return summaryText(answered, static_cast<int>(spec.questions.size()), changed, clarify);
845+
return summaryText(answered, static_cast<int>(spec.questions.size()), changed, clarify,
846+
!note.trimmed().isEmpty());
831847
}
832848

833849
QString summaryFromAnswersText(const QString &text)
@@ -841,16 +857,23 @@ QString summaryFromAnswersText(const QString &text)
841857
return QStringLiteral("Asked to clarify ") + text.mid(from + kLead.size(), to - from - kLead.size());
842858
return QStringLiteral("Asked to clarify a question");
843859
}
844-
if (text.contains(QLatin1String(kDismissedLine)))
845-
return QStringLiteral("Dismissed the form");
860+
if (text.contains(QLatin1String(kDismissedLine))) {
861+
const bool hasNote = text.contains(QString(QLatin1Char('\n')) + kUserNoteLead);
862+
return hasNote ? QStringLiteral("Dismissed the form · note") : QStringLiteral("Dismissed the form");
863+
}
846864
int total = 0;
847865
int answered = 0;
848866
int changed = 0;
849867
int clarify = 0;
868+
bool hasNote = false;
850869
static const QString kClarifyEnd = QStringLiteral("\": ") + kNeedsClarification;
851870
const QStringList lines = text.split(QLatin1Char('\n'));
852871
for (int i = 1; i < lines.size(); ++i) { // line 0 is the header
853872
const QString &line = lines.at(i);
873+
if (line.startsWith(kUserNoteLead)) {
874+
hasNote = true;
875+
continue;
876+
}
854877
if (line.isEmpty() || line.at(0).isSpace() || line.startsWith(QLatin1String("Form: "))
855878
|| !line.contains(QLatin1String("\": "))) {
856879
continue;
@@ -866,7 +889,7 @@ QString summaryFromAnswersText(const QString &text)
866889
if (line.contains(QStringLiteral(" — changed (recommended: ")))
867890
++changed;
868891
}
869-
return summaryText(answered, total, changed, clarify);
892+
return summaryText(answered, total, changed, clarify, hasNote);
870893
}
871894

872895
namespace {
@@ -1116,9 +1139,25 @@ QStringList answerOverview(const QString &text, int maxAnswerChars)
11161139
return out;
11171140
static const QLatin1String kRecommended(" (recommended)");
11181141
static const QString kChanged = QStringLiteral(" — changed (recommended: ");
1142+
auto cut = [maxAnswerChars](QString s) {
1143+
if (maxAnswerChars > 1 && s.size() > maxAnswerChars)
1144+
s = s.left(maxAnswerChars - 1) + QStringLiteral("…");
1145+
return s;
1146+
};
11191147
const QStringList lines = text.split(QLatin1Char('\n'));
11201148
for (int i = 1; i < lines.size(); ++i) {
11211149
const QString &line = lines.at(i);
1150+
if (line.startsWith(kUserNoteLead)) {
1151+
// The form note on one line: its indented continuation joins it.
1152+
QString note = line.mid(kUserNoteLead.size());
1153+
while (i + 1 < lines.size() && lines.at(i + 1).startsWith(QLatin1String(" "))) {
1154+
const QString more = lines.at(++i).trimmed();
1155+
if (!more.isEmpty())
1156+
note += QLatin1Char(' ') + more;
1157+
}
1158+
out.append(QStringLiteral("Note → ") + cut(note));
1159+
continue;
1160+
}
11221161
if (line.isEmpty() || line.at(0).isSpace() || line.startsWith(QLatin1String("Form: ")))
11231162
continue;
11241163
const qsizetype space = line.indexOf(QLatin1Char(' '));
@@ -1150,8 +1189,7 @@ QStringList answerOverview(const QString &text, int maxAnswerChars)
11501189
item = displayItem(item);
11511190
shown = items.join(QStringLiteral(", "));
11521191
}
1153-
if (maxAnswerChars > 1 && shown.size() > maxAnswerChars)
1154-
shown = shown.left(maxAnswerChars - 1) + QStringLiteral("…");
1192+
shown = cut(shown);
11551193
out.append(QStringLiteral("%1 %2 → %3").arg(id, title, shown)
11561194
+ (changed ? QStringLiteral(" · changed") : QString()));
11571195
}
@@ -1162,7 +1200,7 @@ QString dismissPrompt(const QString &note)
11621200
{
11631201
QString out = QLatin1String(kAnswersHeader) + QLatin1Char('\n') + QLatin1String(kDismissedLine);
11641202
if (!note.trimmed().isEmpty())
1165-
out += QStringLiteral("\nNote from the user: ") + note.trimmed();
1203+
out += QLatin1Char('\n') + userNoteLine(note);
11661204
return out;
11671205
}
11681206

‎src/AskUserForm.h‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,20 @@ bool hasAnswer(const Question &q, const Answer &a);
148148
// The answer differs from a defined recommendation.
149149
bool isChanged(const Question &q, const Answer &a);
150150

151-
// The prompt sent to the agent after Submit.
152-
QString formatAnswers(const Spec &spec, const QVector<Answer> &answers);
151+
// The prompt sent to the agent after Submit. `note`: the form-level note the
152+
// user adds below the questions, sent as "Note from the user: …" after the
153+
// answers (omitted when blank).
154+
QString formatAnswers(const Spec &spec, const QVector<Answer> &answers, const QString &note = {});
153155
// One line for the collapsed bubble: "Answered 10 questions · 3 changed",
154-
// plus " · 2 to clarify" when the user flagged questions.
155-
QString answerSummary(const Spec &spec, const QVector<Answer> &answers);
156+
// plus " · 2 to clarify" when the user flagged questions and " · note" when
157+
// the form note is not blank.
158+
QString answerSummary(const Spec &spec, const QVector<Answer> &answers, const QString &note = {});
156159
// Same summary, recomputed from the prompt text (restored history).
157160
QString summaryFromAnswersText(const QString &text);
158161
// One line per question from the prompt text: "Q1 Title → answer[ · changed]",
159-
// answers cut to `maxAnswerChars`. Empty for dismiss and legacy clarify prompts.
162+
// then "Note → text" for the form note; answers and the note cut to
163+
// `maxAnswerChars`. Legacy clarify prompts have none; a dismiss prompt has
164+
// only its note line, if any.
160165
QStringList answerOverview(const QString &text, int maxAnswerChars = 90);
161166

162167
QString dismissPrompt(const QString &note);

‎src/widgets/AcpSessionView.cpp‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,7 @@ void AcpSessionView::rebind(AcpSessionModel *model, AcpConnection *connection)
10861086
m_askUserCandidates.clear();
10871087
m_askUserShown.clear();
10881088
m_carryAnswers.clear();
1089+
m_carryNote.clear();
10891090

10901091
clearBanner();
10911092
if (m_usageIndicator) {
@@ -2482,13 +2483,15 @@ void AcpSessionView::presentForm(const AskUserForm::Spec &spec, FormSource sourc
24822483
{
24832484
if (m_pendingForm) {
24842485
m_carryAnswers = m_pendingForm->answers();
2486+
m_carryNote = m_pendingForm->note();
24852487
settlePendingForm(AskUserFormWidget::State::Superseded, {}, QStringLiteral("decline"));
24862488
}
24872489
// Revised ask_user rounds keep what the user already chose (same ids).
24882490
const QVector<AskUserForm::Answer> initial = source == FormSource::ToolCall && !m_carryAnswers.isEmpty()
24892491
? AskUserForm::carryOver(spec, m_carryAnswers)
24902492
: AskUserForm::defaultAnswers(spec);
24912493
m_carryAnswers.clear();
2494+
const QString carriedNote = std::exchange(m_carryNote, QString());
24922495

24932496
auto *form = new AskUserFormWidget(spec, initial, m_transcriptHost);
24942497
form->setChatFont(chatFont());
@@ -2508,6 +2511,7 @@ void AcpSessionView::presentForm(const AskUserForm::Spec &spec, FormSource sourc
25082511
return;
25092512
}
25102513

2514+
form->setNote(carriedNote);
25112515
m_pendingForm = form;
25122516
m_pendingFormSource = source;
25132517
m_pendingFormKey = key;
@@ -2569,10 +2573,11 @@ void AcpSessionView::onFormSubmitted(const QVector<AskUserForm::Answer> &answers
25692573
const AskUserForm::Spec spec = m_pendingForm->spec();
25702574
const FormSource source = m_pendingFormSource;
25712575
const QString key = m_pendingFormKey;
2576+
const QString note = m_pendingForm->note();
25722577
const bool clarify = std::any_of(answers.cbegin(), answers.cend(),
25732578
[](const AskUserForm::Answer &a) { return a.clarify; });
25742579
settlePendingForm(clarify ? AskUserFormWidget::State::ClarifyRequested : AskUserFormWidget::State::Answered,
2575-
AskUserForm::answerSummary(spec, answers));
2580+
AskUserForm::answerSummary(spec, answers, note));
25762581
if (source == FormSource::Elicitation) {
25772582
if (m_connection) {
25782583
m_connection->respondToElicitation(key, QStringLiteral("accept"),
@@ -2582,7 +2587,8 @@ void AcpSessionView::onFormSubmitted(const QVector<AskUserForm::Answer> &answers
25822587
// The agent re-asks the flagged questions: keep what the user chose.
25832588
if (clarify)
25842589
m_carryAnswers = answers;
2585-
sendHostPrompt(AskUserForm::formatAnswers(spec, answers), QLatin1String(AskUserForm::kMarkerAnswers));
2590+
sendHostPrompt(AskUserForm::formatAnswers(spec, answers, note),
2591+
QLatin1String(AskUserForm::kMarkerAnswers));
25862592
}
25872593
if (m_input)
25882594
m_input->setFocus();
@@ -2594,12 +2600,13 @@ void AcpSessionView::onFormDismissed()
25942600
return;
25952601
const FormSource source = m_pendingFormSource;
25962602
const QString key = m_pendingFormKey;
2603+
const QString note = m_pendingForm->note();
25972604
settlePendingForm(AskUserFormWidget::State::Dismissed);
25982605
if (source == FormSource::Elicitation) {
25992606
if (m_connection)
26002607
m_connection->respondToElicitation(key, QStringLiteral("decline"));
26012608
} else {
2602-
sendHostPrompt(AskUserForm::dismissPrompt({}), QLatin1String(AskUserForm::kMarkerAnswers));
2609+
sendHostPrompt(AskUserForm::dismissPrompt(note), QLatin1String(AskUserForm::kMarkerAnswers));
26032610
}
26042611
}
26052612

‎src/widgets/AcpSessionView.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,8 @@ private slots:
483483
QSet<QString> m_askUserShown;
484484
// Answers of a form that was superseded or sent for clarification.
485485
QVector<AskUserForm::Answer> m_carryAnswers;
486+
// Form note of a superseded form (never sent). A sent note is not carried.
487+
QString m_carryNote;
486488
bool m_scheduledUnattended = false;
487489

488490
bool m_updatingSelectors = false;

‎src/widgets/AskUserFormWidget.cpp‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#include "AskUserFormWidget.h"
1212

13+
#include <QAbstractTextDocumentLayout>
1314
#include <QButtonGroup>
1415
#include <QCheckBox>
1516
#include <QComboBox>
@@ -33,9 +34,12 @@
3334
#include <QStyledItemDelegate>
3435
#include <QWheelEvent>
3536
#include <QStyle>
37+
#include <QTextDocument>
3638
#include <QToolButton>
3739
#include <QVBoxLayout>
3840

41+
#include <algorithm>
42+
#include <cmath>
3943
#include <utility>
4044

4145
using AskUserForm::Answer;
@@ -45,6 +49,7 @@ using AskUserForm::QuestionType;
4549
namespace {
4650

4751
constexpr int kTextareaLines = 4;
52+
constexpr int kNoteMaxLines = 6; // the form note scrolls past this
4853
constexpr qreal kClarifyOpacity = 0.45; // controls of a flagged question: not sent
4954
constexpr int kOtherButtonId = 1 << 20; // past any option index; never a Qt auto id
5055

@@ -456,6 +461,19 @@ void AskUserFormWidget::buildUi()
456461
m_rows.resize(m_spec.questions.size());
457462
for (int i = 0; i < m_spec.questions.size(); ++i)
458463
bodyLayout->addWidget(buildQuestion(i));
464+
// Anything the user remembers that no question asked. Elicitation answers
465+
// are a fixed schema with no place for it.
466+
if (m_spec.source == AskUserForm::Source::AskUser) {
467+
m_formNoteEdit = new QPlainTextEdit(m_body);
468+
m_formNoteEdit->setPlaceholderText(tr("Anything else the agent should know? (optional)"));
469+
m_formNoteEdit->setTabChangesFocus(true);
470+
m_formNoteEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
471+
bodyLayout->addWidget(m_formNoteEdit);
472+
watch(m_formNoteEdit);
473+
connect(m_formNoteEdit->document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged,
474+
this, &AskUserFormWidget::fitNote);
475+
fitNote();
476+
}
459477
root->addWidget(m_body);
460478

461479
m_footer = new QWidget(this);
@@ -839,6 +857,30 @@ void AskUserFormWidget::setClarify(int index, bool on)
839857
updateProgress();
840858
}
841859

860+
void AskUserFormWidget::fitNote()
861+
{
862+
if (!m_formNoteEdit)
863+
return;
864+
// A plain-text document layout measures its height in lines.
865+
const int lines = static_cast<int>(std::ceil(m_formNoteEdit->document()->size().height()));
866+
// No scroll bar while the box fits its text: one would narrow it, rewrap
867+
// the text and resize the box again.
868+
m_formNoteEdit->setVerticalScrollBarPolicy(lines > kNoteMaxLines ? Qt::ScrollBarAsNeeded
869+
: Qt::ScrollBarAlwaysOff);
870+
fitLines(m_formNoteEdit, std::clamp(lines, 1, kNoteMaxLines));
871+
}
872+
873+
QString AskUserFormWidget::note() const
874+
{
875+
return m_formNoteEdit ? m_formNoteEdit->toPlainText().trimmed() : QString();
876+
}
877+
878+
void AskUserFormWidget::setNote(const QString &note)
879+
{
880+
if (m_formNoteEdit)
881+
m_formNoteEdit->setPlainText(note);
882+
}
883+
842884
void AskUserFormWidget::updateProgress()
843885
{
844886
if (m_building || !m_progressLabel)
@@ -1030,6 +1072,9 @@ void AskUserFormWidget::trySubmit()
10301072

10311073
bool AskUserFormWidget::eventFilter(QObject *watched, QEvent *event)
10321074
{
1075+
// The style sheet's frame width applies after the first fit (at polish).
1076+
if (watched == m_formNoteEdit && (event->type() == QEvent::Polish || event->type() == QEvent::StyleChange))
1077+
fitNote();
10331078
if (event->type() != QEvent::KeyPress || !isPending())
10341079
return QFrame::eventFilter(watched, event);
10351080
auto *ke = static_cast<QKeyEvent *>(event);
@@ -1093,6 +1138,9 @@ void AskUserFormWidget::setState(State state, const QString &detail)
10931138
setProperty("pending", pending);
10941139
repolish(this);
10951140
m_body->setEnabled(pending);
1141+
// A settled form keeps its note on show, and no empty box.
1142+
if (m_formNoteEdit)
1143+
m_formNoteEdit->setVisible(pending || !note().isEmpty());
10961144
// Settled forms collapse to their status line; the chevron shows them.
10971145
m_toggle->setVisible(!pending);
10981146
m_toggle->setChecked(pending);
@@ -1119,6 +1167,7 @@ void AskUserFormWidget::setChatFont(const QFont &font)
11191167
if (row.textEdit)
11201168
fitLines(row.textEdit, kTextareaLines);
11211169
}
1170+
fitNote();
11221171
}
11231172

11241173
QWidget *AskUserFormWidget::inputOf(int index) const

‎src/widgets/AskUserFormWidget.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ class AskUserFormWidget : public QFrame
5858
// `detail` overrides the default status text (e.g. the cancel reason).
5959
void setState(State state, const QString &detail = {});
6060
QVector<AskUserForm::Answer> answers() const;
61+
// The form-level note below the questions (trimmed; always empty for
62+
// elicitation, whose answers are a fixed schema).
63+
QString note() const;
64+
void setNote(const QString &note);
6165

6266
void setChatFont(const QFont &font);
6367
void focusFirstQuestion();
@@ -103,6 +107,8 @@ class AskUserFormWidget : public QFrame
103107
void watch(QWidget *w);
104108
void updateProposalEditor(int index);
105109
void setClarify(int index, bool on);
110+
// Grows the note box with its text, one line up to kNoteMaxLines.
111+
void fitNote();
106112
void updateProgress();
107113
void setExpanded(bool expanded);
108114
void applyTheme();
@@ -120,6 +126,7 @@ class AskUserFormWidget : public QFrame
120126
QLabel *m_statusLabel = nullptr;
121127
QToolButton *m_toggle = nullptr;
122128
QWidget *m_body = nullptr;
129+
QPlainTextEdit *m_formNoteEdit = nullptr; // ask_user only
123130
QWidget *m_footer = nullptr;
124131
QLabel *m_progressLabel = nullptr;
125132
QPushButton *m_submitButton = nullptr;

0 commit comments

Comments
 (0)