Skip to content

Commit a643d7c

Browse files
feat(acp): show fold-row diff stats, async agents
Turn folds need a compact "+N -M" so users can see what landed without expanding every tool card. Count only completed calls and the same block shapes the card renders (compact unified/numbered, else old/new), with a linear prefix/suffix fallback past the exact-diff size cap. claude-agent-acp also completes Agent/Task launches at once with no async task when a subagent starts in the background; treat isAsync/async_launched as backgrounded and keep the parent live while child activity continues until the turn settles.
1 parent 1370e8e commit a643d7c

20 files changed

Lines changed: 905 additions & 31 deletions

‎src/AcpConnection.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,8 @@ void AcpConnection::handleInboundNotification(const QString &method, const QJson
10261026
u.status = st.toString();
10271027
trackToolCallStatus(u.id, *u.status);
10281028
}
1029-
if (AcpProtocol::toolCallUpdateIsBackgrounded(update)) {
1029+
if (AcpProtocol::toolCallUpdateIsBackgrounded(update)
1030+
|| AcpProtocol::toolCallUpdateIsAsyncSubagentLaunch(update)) {
10301031
u.backgrounded = true;
10311032
}
10321033
if (QString parent = AcpProtocol::subagentParentToolCallId(update); !parent.isEmpty()) {

‎src/AcpDiffStat.cpp‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#include "AcpDiffStat.h"
20+
21+
#include <QByteArray>
22+
#include <QJsonArray>
23+
#include <QJsonObject>
24+
#include <QList>
25+
#include <QStringView>
26+
27+
#include "git/BufferDiffEngine.h"
28+
29+
namespace AcpDiffStat {
30+
31+
namespace {
32+
33+
constexpr int kMaxBlocks = 256;
34+
35+
// Calls fn(line) for every line of text, without its '\r\n' / '\n'. A final
36+
// newline does not start an extra empty line.
37+
template <typename Fn>
38+
void forEachLine(QStringView text, Fn &&fn)
39+
{
40+
qsizetype start = 0;
41+
const qsizetype n = text.size();
42+
while (start < n) {
43+
qsizetype end = text.indexOf(QLatin1Char('\n'), start);
44+
if (end < 0)
45+
end = n;
46+
QStringView line = text.sliced(start, end - start);
47+
if (line.endsWith(QLatin1Char('\r')))
48+
line.chop(1);
49+
fn(line);
50+
start = end + 1;
51+
}
52+
}
53+
54+
int lineCount(QStringView text)
55+
{
56+
if (text.isEmpty())
57+
return 0;
58+
const int newlines = static_cast<int>(text.count(QLatin1Char('\n')));
59+
return text.endsWith(QLatin1Char('\n')) ? newlines : newlines + 1;
60+
}
61+
62+
enum class Mark : quint8 { None, Context, Add, Remove };
63+
64+
// omp's numbered row, as AcpToolCallCard parses it: "[ ]*[+-]?N[ ]*(|or│)text".
65+
Mark numberedMark(QStringView line)
66+
{
67+
qsizetype i = 0;
68+
const qsizetype n = line.size();
69+
while (i < n && line.at(i) == QLatin1Char(' '))
70+
++i;
71+
if (i >= n)
72+
return Mark::None;
73+
Mark mark = Mark::Context;
74+
if (line.at(i) == QLatin1Char('+')) {
75+
mark = Mark::Add;
76+
++i;
77+
} else if (line.at(i) == QLatin1Char('-')) {
78+
mark = Mark::Remove;
79+
++i;
80+
}
81+
const qsizetype digits = i;
82+
bool nonZero = false;
83+
while (i < n && line.at(i) >= QLatin1Char('0') && line.at(i) <= QLatin1Char('9')) {
84+
nonZero = nonZero || line.at(i) != QLatin1Char('0');
85+
++i;
86+
}
87+
if (i == digits || !nonZero)
88+
return Mark::None;
89+
while (i < n && line.at(i) == QLatin1Char(' '))
90+
++i;
91+
if (i >= n || (line.at(i) != QLatin1Char('|') && line.at(i) != QChar(0x2502)))
92+
return Mark::None;
93+
return mark;
94+
}
95+
96+
// The compact `diff` string: omp numbered rows when the first non-empty line
97+
// is one, else unified. False when it holds no diff rows (the card then falls
98+
// back to oldText/newText, and so does the count).
99+
bool countCompact(QStringView diff, Lines *out)
100+
{
101+
bool decided = false;
102+
bool numbered = false;
103+
bool anyRow = false;
104+
Lines lines;
105+
forEachLine(diff, [&](QStringView line) {
106+
if (line.isEmpty())
107+
return;
108+
if (!decided) {
109+
decided = true;
110+
numbered = numberedMark(line) != Mark::None;
111+
}
112+
if (numbered) {
113+
switch (numberedMark(line)) {
114+
case Mark::Add: ++lines.added; anyRow = true; break;
115+
case Mark::Remove: ++lines.removed; anyRow = true; break;
116+
case Mark::Context: anyRow = true; break;
117+
case Mark::None: break;
118+
}
119+
return;
120+
}
121+
if (line.startsWith(QLatin1String("+++")) || line.startsWith(QLatin1String("---")))
122+
return;
123+
switch (line.at(0).unicode()) {
124+
case '+': ++lines.added; anyRow = true; break;
125+
case '-': ++lines.removed; anyRow = true; break;
126+
case ' ':
127+
case '\t': anyRow = true; break;
128+
default: break;
129+
}
130+
});
131+
if (anyRow)
132+
*out = lines;
133+
return anyRow;
134+
}
135+
136+
QList<QStringView> splitLines(QStringView text)
137+
{
138+
QList<QStringView> out;
139+
out.reserve(lineCount(text));
140+
forEachLine(text, [&out](QStringView line) { out.append(line); });
141+
return out;
142+
}
143+
144+
// Linear fallback for huge texts: common prefix and suffix are unchanged, the
145+
// middle counts as one replace (what the card renders for them too).
146+
Lines prefixSuffix(QStringView oldText, QStringView newText)
147+
{
148+
const QList<QStringView> a = splitLines(oldText);
149+
const QList<QStringView> b = splitLines(newText);
150+
qsizetype pre = 0;
151+
while (pre < a.size() && pre < b.size() && a.at(pre) == b.at(pre))
152+
++pre;
153+
qsizetype oldEnd = a.size();
154+
qsizetype newEnd = b.size();
155+
while (oldEnd > pre && newEnd > pre && a.at(oldEnd - 1) == b.at(newEnd - 1)) {
156+
--oldEnd;
157+
--newEnd;
158+
}
159+
return {static_cast<int>(newEnd - pre), static_cast<int>(oldEnd - pre)};
160+
}
161+
162+
Lines countText(const QString &oldText, const QString &newText)
163+
{
164+
if (oldText.size() > kMaxExactDiffChars || newText.size() > kMaxExactDiffChars)
165+
return prefixSuffix(oldText, newText);
166+
QByteArray a = oldText.toUtf8();
167+
QByteArray b = newText.toUtf8();
168+
// Edit snippets usually lack a final newline; without one on both sides
169+
// xdiff would see the old last line as changed when a line is appended.
170+
if (!a.endsWith('\n'))
171+
a.append('\n');
172+
if (!b.endsWith('\n'))
173+
b.append('\n');
174+
Lines lines;
175+
for (const BufferDiffEngine::Hunk &h : BufferDiffEngine::diff(a, b)) {
176+
lines.removed += h.oldCount;
177+
lines.added += h.newCount;
178+
}
179+
return lines;
180+
}
181+
182+
} // namespace
183+
184+
Lines forBlock(const QJsonObject &block)
185+
{
186+
const QJsonValue compact = block.value(QLatin1String("diff"));
187+
if (compact.isString()) {
188+
Lines lines;
189+
if (countCompact(compact.toString(), &lines))
190+
return lines;
191+
}
192+
const QJsonValue oldVal = block.value(QLatin1String("oldText"));
193+
const QJsonValue newVal = block.value(QLatin1String("newText"));
194+
const QString oldText = oldVal.isString() ? oldVal.toString() : QString();
195+
const QString newText = newVal.isString() ? newVal.toString() : QString();
196+
if (oldText.isEmpty())
197+
return {lineCount(newText), 0};
198+
if (newText.isEmpty())
199+
return {0, lineCount(oldText)};
200+
return countText(oldText, newText);
201+
}
202+
203+
Lines forContent(const QJsonArray &content)
204+
{
205+
Lines total;
206+
const qsizetype n = qMin<qsizetype>(content.size(), kMaxBlocks);
207+
for (qsizetype i = 0; i < n; ++i) {
208+
const QJsonValue item = content.at(i);
209+
if (!item.isObject())
210+
continue;
211+
const QJsonObject block = item.toObject();
212+
if (block.value(QLatin1String("type")).toString() == QLatin1String("diff"))
213+
total += forBlock(block);
214+
}
215+
return total;
216+
}
217+
218+
Lines forToolCall(const AcpProtocol::AcpToolCall &tc)
219+
{
220+
if (tc.status != QLatin1String("completed"))
221+
return {};
222+
return forContent(tc.content);
223+
}
224+
225+
QString label(const Lines &lines)
226+
{
227+
if (lines.isEmpty())
228+
return {};
229+
return QStringLiteral("+%1 -%2").arg(lines.added).arg(lines.removed);
230+
}
231+
232+
} // namespace AcpDiffStat

‎src/AcpDiffStat.h‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* This file is part of Notepad Next.
3+
* Copyright 2026 NotepadAI contributors
4+
*
5+
* Notepad Next is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* Notepad Next is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Notepad Next. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
#ifndef ACP_DIFF_STAT_H
20+
#define ACP_DIFF_STAT_H
21+
22+
#include <QString>
23+
24+
#include "AcpProtocol.h"
25+
26+
class QJsonArray;
27+
class QJsonObject;
28+
29+
// Lines added / removed by a tool call's `diff` content blocks, for the
30+
// "Worked for Xs · N tool calls · +6 -10" fold row. Reads the same block
31+
// shapes AcpToolCallCard renders: a compact diff (unified or omp numbered)
32+
// wins, else oldText/newText (oldText null or empty = new file).
33+
//
34+
// Cost: one linear scan of the compact diff, or one xdiff (histogram) run
35+
// over oldText/newText; above kMaxExactDiffChars per side the count falls back
36+
// to common prefix/suffix trimming, linear in the text size. Pure, no state.
37+
namespace AcpDiffStat {
38+
39+
inline constexpr qsizetype kMaxExactDiffChars = 512 * 1024;
40+
41+
// Added / removed marks, shared by the tool-call card's diff rows and the fold
42+
// row. Content accents (like the diff itself), not chrome.
43+
inline constexpr char kAddedColor[] = "#4caf50";
44+
inline constexpr char kRemovedColor[] = "#d9534f";
45+
46+
struct Lines
47+
{
48+
int added = 0;
49+
int removed = 0;
50+
51+
bool isEmpty() const noexcept { return added == 0 && removed == 0; }
52+
Lines &operator+=(const Lines &other) noexcept
53+
{
54+
added += other.added;
55+
removed += other.removed;
56+
return *this;
57+
}
58+
};
59+
60+
Lines forBlock(const QJsonObject &block);
61+
Lines forContent(const QJsonArray &content);
62+
// Only a completed call changed anything: a pending call's diff is a proposal,
63+
// a failed or cancelled one may not have landed.
64+
Lines forToolCall(const AcpProtocol::AcpToolCall &tc);
65+
66+
// "+6 -10"; empty when nothing changed.
67+
QString label(const Lines &lines);
68+
69+
} // namespace AcpDiffStat
70+
71+
#endif // ACP_DIFF_STAT_H

‎src/AcpProtocol.cpp‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,18 @@ bool toolCallUpdateIsBackgrounded(const QJsonObject &update)
562562
.value(QStringLiteral("backgrounded")).toBool(false);
563563
}
564564

565+
bool toolCallUpdateIsAsyncSubagentLaunch(const QJsonObject &update)
566+
{
567+
const QJsonValue metaValue = update.value(QStringLiteral("_meta"));
568+
if (!metaValue.isObject())
569+
return false;
570+
const QJsonObject response = metaValue.toObject()
571+
.value(QStringLiteral("claudeCode")).toObject()
572+
.value(QStringLiteral("toolResponse")).toObject();
573+
return response.value(QStringLiteral("isAsync")).toBool(false)
574+
|| response.value(QStringLiteral("status")).toString() == QLatin1String("async_launched");
575+
}
576+
565577
QString subagentParentToolCallId(const QJsonObject &update)
566578
{
567579
const QJsonValue metaValue = update.value(QStringLiteral("_meta"));

‎src/AcpProtocol.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@ bool asyncTaskStateIsLive(const QString &state);
352352
// `_meta.jetbrains.air.asyncTasks.backgrounded == true` on a tool_call_update.
353353
bool toolCallUpdateIsBackgrounded(const QJsonObject &update);
354354

355+
// `_meta.claudeCode.toolResponse` of an Agent/Task call whose subagent was
356+
// launched in the background (`isAsync: true` / `status: "async_launched"`).
357+
// claude-agent-acp reports such a call `completed` at once and publishes no
358+
// async task for the subagent, so this marker is the only launch signal.
359+
bool toolCallUpdateIsAsyncSubagentLaunch(const QJsonObject &update);
360+
355361
// The Agent/Task tool call whose subagent sent this session/update, from its
356362
// `_meta`: `parentToolCallId`, then `claudeCode.parentToolUseId`
357363
// (claude-agent-acp), then `parentToolUseId`. Empty for the main agent, and

0 commit comments

Comments
 (0)