Skip to content

Commit 1370e8e

Browse files
feat(acp): show subagent cards, fix layout
Subagent tool calls need identifiable instructions on the card and in history so parent runs stay readable after reload. Cap stored text so large omp task batches cannot bloat the session. Long agent/user text must not drive minimum widths past the transcript; that made QBoxLayout size to the minimum, squashing rows and clipping the last lines under AlignRight.
1 parent f0cbdc2 commit 1370e8e

17 files changed

Lines changed: 1347 additions & 89 deletions

‎src/AcpProtocol.cpp‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,92 @@ QString subagentParentToolCallId(const QJsonObject &update)
580580
return parent;
581581
}
582582

583+
namespace {
584+
585+
constexpr int kMaxHistoryInstructionChars = 32000;
586+
587+
bool isOmpTaskInput(const QJsonObject &rawInput)
588+
{
589+
const QJsonValue tasks = rawInput.value(QLatin1String("tasks"));
590+
if (!tasks.isArray()) return false;
591+
const QJsonArray arr = tasks.toArray();
592+
return !arr.isEmpty() && arr.first().toObject().value(QLatin1String("task")).isString();
593+
}
594+
595+
} // namespace
596+
597+
bool isSubagentToolInput(const QJsonObject &rawInput)
598+
{
599+
if (rawInput.contains(QLatin1String("subagent_type")))
600+
return true;
601+
if (rawInput.contains(QLatin1String("description"))
602+
&& !rawInput.value(QLatin1String("prompt")).toString().isEmpty())
603+
return true; // WebFetch's prompt comes with a url, not a description
604+
return isOmpTaskInput(rawInput);
605+
}
606+
607+
int subagentToolCount(const QJsonObject &rawInput)
608+
{
609+
return isOmpTaskInput(rawInput)
610+
? static_cast<int>(rawInput.value(QLatin1String("tasks")).toArray().size())
611+
: 1;
612+
}
613+
614+
QString subagentInstructions(const QJsonObject &rawInput)
615+
{
616+
if (!isOmpTaskInput(rawInput)) {
617+
if (!isSubagentToolInput(rawInput)) return {};
618+
return rawInput.value(QLatin1String("prompt")).toString().trimmed();
619+
}
620+
QString md = rawInput.value(QLatin1String("context")).toString().trimmed();
621+
const QJsonArray tasks = rawInput.value(QLatin1String("tasks")).toArray();
622+
for (const QJsonValue &v : tasks) {
623+
const QJsonObject task = v.toObject();
624+
const QString text = task.value(QLatin1String("task")).toString().trimmed();
625+
const QString name = task.value(QLatin1String("name")).toString().trimmed();
626+
if (text.isEmpty() && name.isEmpty()) continue;
627+
if (!md.isEmpty()) md += QLatin1String("\n\n");
628+
if (!name.isEmpty())
629+
md += QLatin1String("**") + name + QLatin1String("**\n\n");
630+
md += text;
631+
}
632+
return md;
633+
}
634+
635+
QJsonObject subagentInputForHistory(const QJsonObject &rawInput)
636+
{
637+
auto capped = [](QString text) {
638+
if (text.size() > kMaxHistoryInstructionChars) text.truncate(kMaxHistoryInstructionChars);
639+
return text;
640+
};
641+
QJsonObject out;
642+
if (!isOmpTaskInput(rawInput)) {
643+
const QString prompt = rawInput.value(QLatin1String("prompt")).toString();
644+
if (!prompt.isEmpty()) out.insert(QStringLiteral("prompt"), capped(prompt));
645+
return out;
646+
}
647+
for (const QLatin1String key : {QLatin1String("i"), QLatin1String("agent")}) {
648+
const QString value = rawInput.value(key).toString();
649+
if (!value.isEmpty()) out.insert(key, value.left(200));
650+
}
651+
// One budget across the context and every task.
652+
int budget = kMaxHistoryInstructionChars;
653+
const QString context = rawInput.value(QLatin1String("context")).toString().left(budget);
654+
budget -= static_cast<int>(context.size());
655+
if (!context.isEmpty()) out.insert(QStringLiteral("context"), context);
656+
QJsonArray tasks;
657+
const QJsonArray source = rawInput.value(QLatin1String("tasks")).toArray();
658+
for (const QJsonValue &v : source) {
659+
const QJsonObject task = v.toObject();
660+
const QString text = task.value(QLatin1String("task")).toString().left(qMax(0, budget));
661+
budget -= static_cast<int>(text.size());
662+
tasks.append(QJsonObject{{QStringLiteral("name"), task.value(QLatin1String("name")).toString()},
663+
{QStringLiteral("task"), text}});
664+
}
665+
out.insert(QStringLiteral("tasks"), tasks);
666+
return out;
667+
}
668+
583669
QString formatWaitDuration(qint64 ms)
584670
{
585671
if (ms < 0) {

‎src/AcpProtocol.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,18 @@ bool toolCallUpdateIsBackgrounded(const QJsonObject &update);
358358
// when the id names the update's own tool call.
359359
QString subagentParentToolCallId(const QJsonObject &update);
360360

361+
// A tool call that runs subagents, from its rawInput: Claude Code's Agent/Task
362+
// (`subagent_type`, or `description` + `prompt`) or omp's task
363+
// (`tasks: [{name, task}]`, optional `context`, `agent`, intent `i`).
364+
bool isSubagentToolInput(const QJsonObject &rawInput);
365+
// Number of subagents the call starts: omp's task count, else 1.
366+
int subagentToolCount(const QJsonObject &rawInput);
367+
// The instructions those subagents get, as markdown (omp: the shared context,
368+
// then each task under its bold name). Empty when the call carries none.
369+
QString subagentInstructions(const QJsonObject &rawInput);
370+
// The rawInput fields history keeps for such a call (instructions capped).
371+
QJsonObject subagentInputForHistory(const QJsonObject &rawInput);
372+
361373
// Compact wait clock: "4.2s" under a minute, "2m 05s" under an hour, then "1h 02m".
362374
QString formatWaitDuration(qint64 ms);
363375
// Compact tool run time: "<1s", "40s", "1m5s", "1h1m5s".

‎src/AcpSessionModel.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,13 @@ QJsonObject AcpSessionModel::toHistoryJson() const
376376
copyField(QLatin1String("information_request"));
377377
copyField(QLatin1String("skill"));
378378
copyField(QLatin1String("subagent_type"));
379+
// A subagent call's instructions: its card shows them above the
380+
// subagent's calls, and they mark the card as a subagent's.
381+
if (AcpProtocol::isSubagentToolInput(tc.rawInput)) {
382+
const QJsonObject kept = AcpProtocol::subagentInputForHistory(tc.rawInput);
383+
for (auto it = kept.constBegin(); it != kept.constEnd(); ++it)
384+
slim.insert(it.key(), it.value());
385+
}
379386
if (tc.rawInput.contains(QLatin1String("command"))) {
380387
QString cmd = tc.rawInput.value(QLatin1String("command")).toString();
381388
if (cmd.isEmpty() && tc.rawInput.value(QLatin1String("command")).isArray()) {

‎src/icons/bot.svg‎

Lines changed: 1 addition & 0 deletions
Loading

‎src/resources.qrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
<file>icons/square-terminal.svg</file>
5252
<file>icons/play.svg</file>
5353
<file>icons/check.svg</file>
54+
<file>icons/bot.svg</file>
5455
<file>icons/circle-x.svg</file>
5556
<file>icons/eraser.svg</file>
5657
<file>icons/chevron-up.svg</file>

‎src/widgets/AcpMessageWidget.cpp‎

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353

5454
#include <cmath>
5555
#include <cstdint>
56+
#include <functional>
57+
#include <utility>
5658

5759
namespace {
5860

@@ -76,6 +78,44 @@ constexpr int kThoughtBrowserPadLeft = 4;
7678
constexpr int kUserBubblePadH = 14;
7779
constexpr int kUserBubbleGap = 32; // space kept free to the left of a full-width user bubble
7880
constexpr int kThoughtItalicRightSlack = 4;
81+
82+
// A word that cannot wrap (a path, a URL) must not set the bubble's minimum
83+
// width. A bubble whose minimum is wider than the transcript gets laid out by
84+
// QBoxLayout for its minimum width, not the width it has: the height comes out
85+
// short and the last lines are clipped. The word is clipped instead.
86+
void allowShrink(QWidget *w)
87+
{
88+
w->setMinimumWidth(1);
89+
}
90+
91+
// The transcript layout asks a bubble's layout (QWidgetItem::heightForWidth),
92+
// not the bubble, for its height at the full transcript width; AlignRight then
93+
// narrows the bubble to its hint. Answer for the width the bubble really gets,
94+
// or lines that wrap only at that width are clipped.
95+
class UserBubbleLayout : public QVBoxLayout
96+
{
97+
public:
98+
UserBubbleLayout(QWidget *parent, std::function<int()> width)
99+
: QVBoxLayout(parent)
100+
, m_width(std::move(width))
101+
{
102+
}
103+
104+
int heightForWidth(int w) const override { return QVBoxLayout::heightForWidth(qMin(w, m_width())); }
105+
106+
// Runs on every text, font or visibility change of a child.
107+
void invalidate() override
108+
{
109+
contentWidth = -1;
110+
QVBoxLayout::invalidate();
111+
}
112+
113+
// AcpMessageWidget::measureUserContent() (text shaping), -1 = stale.
114+
mutable int contentWidth = -1;
115+
116+
private:
117+
std::function<int()> m_width;
118+
};
79119
// Final-report action row: square icon buttons, glyph per ui-dna toolbar chrome.
80120
constexpr int kResponseActionsH = 22;
81121
constexpr int kResponseActionIcon = 14;
@@ -135,7 +175,11 @@ AcpMessageWidget::AcpMessageWidget(QString role, QWidget *parent)
135175
QString::fromLatin1(kFrameStyleThought) +
136176
QString::fromLatin1(kFrameStyleSystem));
137177

138-
m_layout = new QVBoxLayout(this);
178+
if (m_role == QLatin1String("user")) {
179+
m_layout = new UserBubbleLayout(this, [this]() { return userBubbleWidth(); });
180+
} else {
181+
m_layout = new QVBoxLayout(this);
182+
}
139183
if (m_role == QLatin1String("thought")) {
140184
m_layout->setContentsMargins(4, 4, 4, 4);
141185
} else if (m_role == QLatin1String("user")) {
@@ -300,6 +344,7 @@ void AcpMessageWidget::setContent(const QVector<AcpProtocol::AcpContentBlock> &c
300344
if (!pendingTextLabel) {
301345
pendingTextLabel = new QLabel(this);
302346
pendingTextLabel->setWordWrap(true);
347+
allowShrink(pendingTextLabel);
303348
pendingTextLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
304349
// Stamp the chat font now: this QLabel is created after the
305350
// view's initial setChatFont() pass, and being inside a
@@ -360,6 +405,7 @@ void AcpMessageWidget::setCollapsedSummary(const QString &summary, const QString
360405
m_summaryHeader->setCheckable(true);
361406
m_summaryHeader->setAutoRaise(true);
362407
m_summaryHeader->setToolButtonStyle(Qt::ToolButtonTextOnly);
408+
allowShrink(m_summaryHeader);
363409
m_summaryHeader->setStyleSheet(QStringLiteral(
364410
"QToolButton { border: none; padding: 0; margin: 0; text-align: left; }"));
365411
m_summaryHeader->setCursor(Qt::PointingHandCursor);
@@ -369,6 +415,7 @@ void AcpMessageWidget::setCollapsedSummary(const QString &summary, const QString
369415
m_summaryOverview = new QLabel(this);
370416
m_summaryOverview->setTextFormat(Qt::PlainText);
371417
m_summaryOverview->setWordWrap(true);
418+
allowShrink(m_summaryOverview);
372419
m_summaryOverview->setTextInteractionFlags(Qt::TextSelectableByMouse);
373420
if (m_chatFontSet)
374421
m_summaryOverview->setFont(m_chatFont);
@@ -463,8 +510,28 @@ QSize AcpMessageWidget::sizeHint() const
463510
if (m_role != QLatin1String("user") || !m_layout)
464511
return QFrame::sizeHint();
465512

513+
const int w = userBubbleWidth();
514+
const int h = m_layout->hasHeightForWidth() ? m_layout->totalHeightForWidth(w)
515+
: QFrame::sizeHint().height();
516+
return QSize(w, h);
517+
}
518+
519+
int AcpMessageWidget::userBubbleWidth() const
520+
{
466521
int marginL = 0, marginT = 0, marginR = 0, marginB = 0;
467522
m_layout->getContentsMargins(&marginL, &marginT, &marginR, &marginB);
523+
const auto *layout = static_cast<const UserBubbleLayout *>(m_layout);
524+
if (layout->contentWidth < 0)
525+
layout->contentWidth = measureUserContent();
526+
// +2 border, +2 so a line that exactly fits is not wrapped by rounding.
527+
int w = layout->contentWidth + marginL + marginR + 4;
528+
if (const QWidget *host = parentWidget())
529+
w = qMin(w, qMax(host->width() - kUserBubbleGap, 120));
530+
return w;
531+
}
532+
533+
int AcpMessageWidget::measureUserContent() const
534+
{
468535
int contentW = 0;
469536
if (m_summaryHeader)
470537
contentW = m_summaryHeader->sizeHint().width();
@@ -487,13 +554,7 @@ QSize AcpMessageWidget::sizeHint() const
487554
if (!ui.original.isNull() && !ui.label->isHidden())
488555
contentW = qMax(contentW, ui.original.width());
489556
}
490-
// +2 border, +2 so a line that exactly fits is not wrapped by rounding.
491-
int w = contentW + marginL + marginR + 4;
492-
if (const QWidget *host = parentWidget())
493-
w = qMin(w, qMax(host->width() - kUserBubbleGap, 120));
494-
const int h = m_layout->hasHeightForWidth() ? m_layout->totalHeightForWidth(w)
495-
: QFrame::sizeHint().height();
496-
return QSize(w, h);
557+
return contentW;
497558
}
498559

499560
void AcpMessageWidget::refitBrowserHeight()

‎src/widgets/AcpMessageWidget.h‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ class AcpMessageWidget : public QFrame
118118
bool eventFilter(QObject *watched, QEvent *event) override;
119119

120120
private:
121+
// The width sizeHint() reports for a user bubble: its widest line, capped
122+
// to the transcript.
123+
int userBubbleWidth() const;
124+
// Widest unwrapped line / image of a user bubble (text shaping: cached by
125+
// its layout until a child changes).
126+
int measureUserContent() const;
121127
void rerender();
122128
void refitBrowserHeight();
123129
void scheduleRefit();
@@ -148,7 +154,7 @@ class AcpMessageWidget : public QFrame
148154
QLabel *m_summaryOverview = nullptr; // one line per answer while collapsed
149155
QString m_summary;
150156
QLabel *m_goalBadge = nullptr; // Goal badge on result rows
151-
QVBoxLayout *m_layout = nullptr;
157+
QVBoxLayout *m_layout = nullptr; // user role: UserBubbleLayout
152158

153159
// Final-report action row (assistant role), built on first enable.
154160
QWidget *m_responseActions = nullptr;

0 commit comments

Comments
 (0)