Skip to content

Commit 29bf7dd

Browse files
feat(control): panels, resize, goals, tasks and log args for external agents
Fixes from an external agent's difficulty report (CONTROL_REPORT.md): - QDockWidget panels (workspace panels) get an adapter: select, close, float. A tabified panel behind its tab is off-screen, not hidden, so reachability uses ids::isShownToUser / isBehindTab. /instance lists workspacePanels. - /ui/do op=resize drags the nearest edge setting that size (panel separator, floating window, QSplitter / ADS CDockSplitter handle, the central area via a panel beside it). - Hidden-action twins (ids::setAlternative): the 409 names the visible one. - Goal dialogs, Edit Tasks and the empty terminal panel's task rows have stable ids; text-less buttons are labelled by the labels inside them; /instance lists workspaceTasks (no env). - /log entries and action events carry args (strings capped, password fields masked). - Commands outside an open popup menu are refused (except a click, which closes it): closing the menu's owner under its exec() double-freed it. - Lint fixes in src/control; skill version 3; README mentions the API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d0a83e1 commit 29bf7dd

24 files changed

Lines changed: 668 additions & 55 deletions

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ In a chat, a finished turn folds into a single "Worked for 2m14s" row; click it
1414

1515
There is also a Goal Agent: pick an ACP judge (Claude Code, Codex, Opencode) or a Custom API endpoint that speaks Anthropic's Messages API. You set success criteria and it drives the working agent until those criteria hold, or attach a goal to an existing conversation. Criteria presets can be saved globally, or into the current project's `.agents/.goals` folder so they travel with the repo; files already in that folder show up in the load list. A judge that needs a human action, or that hits the iteration cap with the criterion still unmet, stops and hands the criterion back instead of marking the goal done. With Prefix /goal checked, the first criterion is sent immediately when the agent supports `/goal`. If a reply is still running and the agent does not support `/goal`, that goal waits in the send queue so the current reply is not stopped. Later criteria wait until the previous is achieved. Send with Goal defaults to the agent's own `/goal` command instead: each criterion goes out on its own, with no separate judge. A `/goal` typed in the composer is sent while a turn is still running only when the agent supports that command; otherwise it waits in the queue. A goal is not judged, and auto compact does not run, while the agent still has background tasks running or a wake-up turn pending. When a goal is achieved, the session can compact automatically. Prompts sent while an agent is still working are queued and delivered when it finishes. AI chats group by project, with a session strip to switch conversations in the same dock. Open chats come back after a restart and resume their agent session on the first prompt. A local Claude Code chat can be moved to another agent from the agent button in the dock's title bar, keeping its transcript. The composer can ask the agent to work in a new git worktree. When you commit, AI can write the commit message from your staged diff. That generator (and other LLM calls) can talk to an OpenAI-compatible endpoint or Anthropic's Messages API. Leave the commit box empty (when not amending) and Commit lets you pick an ACP agent to do the commit. Protocol details and how to wire up a custom ACP agent are in [doc/AcpAgents.md](doc/AcpAgents.md).
1616

17+
An outside AI agent can also drive NotepadAI itself. AI ▸ Connect External Agent turns on a local HTTP API (off by default, token-protected) and gives you a prompt to hand to the agent; it fetches a skill from the app describing how to use it. The agent works only through the controls you see: menus, buttons, fields, tabs, editors, terminals and chats, each step highlighted on screen and listed in an activity log, with a Stop button in the status bar. Workspaces, terminals and tasks, AI sessions, goals, git, settings and pane sizes are all reachable this way.
18+
1719
### Git
1820

1921
Inline blame, gutter diff markers, commit history, staging and unstaging (including bulk actions on a status section), a branch picker, and git status decorations in the file tree. Linked worktrees show up in the repository list; you can remove one, or merge its branch into the main checkout and then remove it. Merge and rebase flows are included, with an interactive-rebase editor and a 3-way conflict viewer. Diffs and commits open as editor tabs in a GitHub-style view with a file tree, collapsible files, word-level highlights, and syntax colouring. Most operations never shell out to `git`: it diffs with xdiff and parses status, log, and blame itself.

‎src/control/ControlActionIndex.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
#include <QString>
1919
#include <QStringList>
2020

21+
#include <cstdint>
22+
2123
class QAction;
2224
class QMainWindow;
2325
class QMenu;
@@ -31,7 +33,7 @@ namespace control {
3133
class ControlActionIndex
3234
{
3335
public:
34-
enum class Location { MenuBar, ToolBar, Window, Popup };
36+
enum class Location : std::uint8_t { MenuBar, ToolBar, Window, Popup };
3537

3638
struct Entry
3739
{

‎src/control/ControlActivityLog.cpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
#include <QBrush>
1414
#include <QGuiApplication>
15+
#include <QJsonDocument>
1516
#include <QJsonObject>
1617
#include <QPalette>
1718

@@ -59,11 +60,23 @@ QString ControlActivityLog::displayText(const Entry &e)
5960
{
6061
QString text = e.time.toString(QStringLiteral("HH:mm:ss")) + QStringLiteral(" ") + e.op
6162
+ QStringLiteral(" ") + (e.label.isEmpty() ? e.target : e.label);
63+
if (!e.args.isEmpty())
64+
text += QStringLiteral(" ← ") + argsText(e.args);
6265
if (!e.ok)
6366
text += QStringLiteral(" — ") + e.detail;
6467
return text;
6568
}
6669

70+
QString ControlActivityLog::argsText(const QJsonObject &args)
71+
{
72+
if (args.size() == 1) {
73+
const QJsonValue v = *args.constBegin();
74+
if (v.isString())
75+
return v.toString();
76+
}
77+
return QString::fromUtf8(QJsonDocument(args).toJson(QJsonDocument::Compact));
78+
}
79+
6780
QJsonArray ControlActivityLog::toJson(int limit) const
6881
{
6982
QJsonArray out;
@@ -78,6 +91,8 @@ QJsonArray ControlActivityLog::toJson(int limit) const
7891
o.insert(QStringLiteral("label"), e.label);
7992
if (!e.detail.isEmpty())
8093
o.insert(QStringLiteral("detail"), e.detail);
94+
if (!e.args.isEmpty())
95+
o.insert(QStringLiteral("args"), e.args);
8196
out.append(o);
8297
}
8398
return out;

‎src/control/ControlActivityLog.h‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#include <QAbstractListModel>
1515
#include <QDateTime>
1616
#include <QJsonArray>
17+
#include <QJsonObject>
1718
#include <QString>
1819
#include <QVector>
1920

@@ -35,7 +36,8 @@ class ControlActivityLog : public QAbstractListModel
3536
QString target; // id
3637
QString label; // what the user sees ("AI ▸ AI Agents…")
3738
bool ok = true;
38-
QString detail; // error or extra
39+
QString detail; // error or extra
40+
QJsonObject args; // the request minus its id: value, text, op + args, ...
3941
};
4042

4143
explicit ControlActivityLog(QObject *parent = nullptr);
@@ -47,6 +49,8 @@ class ControlActivityLog : public QAbstractListModel
4749
// Newest `limit` entries, oldest first.
4850
QJsonArray toJson(int limit = kCapacity) const;
4951
static QString displayText(const Entry &entry);
52+
// The arguments as one short line: a lone text/value/keys as itself, else compact JSON.
53+
static QString argsText(const QJsonObject &args);
5054

5155
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
5256
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;

‎src/control/ControlApi.cpp‎

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ namespace control {
4545

4646
namespace {
4747

48+
// NOLINTBEGIN(bugprone-throwing-static-initialization): ApplicationSetting stores std::function (file-scope idiom).
4849
const ApplicationSetting<bool> kEnabled{"ControlApi/Enabled", false};
4950
const ApplicationSetting<int> kPort{"ControlApi/Port", int(ControlApi::kDefaultPort)};
5051
const ApplicationSetting<bool> kAllowLan{"ControlApi/AllowLan", false};
5152
const ApplicationSetting<QString> kLanAddress{"ControlApi/LanAddress", QString()};
53+
// NOLINTEND(bugprone-throwing-static-initialization)
5254

5355
// Object name of a pending /wait (a child of the API, deleted on Stop).
5456
constexpr char kPendingWait[] = "controlPendingWait";
@@ -198,7 +200,7 @@ void ControlApi::setAllowLan(bool on)
198200

199201
QString ControlApi::lanAddress() const
200202
{
201-
const QString saved = m_host.settings ? m_host.settings->get(kLanAddress) : QString();
203+
QString saved = m_host.settings ? m_host.settings->get(kLanAddress) : QString();
202204
const QStringList available = lanAddresses();
203205
if (available.contains(saved))
204206
return saved;
@@ -353,7 +355,7 @@ void ControlApi::stopServer()
353355

354356
void ControlApi::route(const QByteArray &method, const QByteArray &path, Handler handler)
355357
{
356-
m_routes.insert(routeKey(method, path), std::move(handler));
358+
m_routes.emplace(routeKey(method, path), std::move(handler));
357359
}
358360

359361
void ControlApi::notePicker(const QString &id, const QString &valueHint)
@@ -366,7 +368,7 @@ QStringList ControlApi::pickerValues(const QJsonValue &value)
366368
QStringList out;
367369
if (value.isArray()) {
368370
const QJsonArray array = value.toArray();
369-
for (const QJsonValue &v : array)
371+
for (const auto &v : array)
370372
out.append(v.toVariant().toString());
371373
} else {
372374
out = value.toVariant().toString().split(QLatin1Char('\n'));
@@ -375,7 +377,7 @@ QStringList ControlApi::pickerValues(const QJsonValue &value)
375377
}
376378

377379
void ControlApi::logAction(const QString &op, const QString &target, const QString &label, bool ok,
378-
const QString &detail)
380+
const QString &detail, const QJsonObject &args)
379381
{
380382
ControlActivityLog::Entry e;
381383
e.time = QDateTime::currentDateTime();
@@ -384,12 +386,15 @@ void ControlApi::logAction(const QString &op, const QString &target, const QStri
384386
e.label = label;
385387
e.ok = ok;
386388
e.detail = detail;
389+
e.args = args;
387390
if (events::wanted()) {
388391
QJsonObject data{{QStringLiteral("op"), op}, {QStringLiteral("label"), label}, {QStringLiteral("ok"), ok}};
389392
if (!target.isEmpty())
390393
data.insert(QStringLiteral("id"), target);
391394
if (!detail.isEmpty())
392395
data.insert(QStringLiteral("detail"), detail);
396+
if (!args.isEmpty())
397+
data.insert(QStringLiteral("args"), args);
393398
events::publish(QStringLiteral("action"), data);
394399
}
395400
m_log->append(std::move(e));
@@ -530,7 +535,16 @@ void ControlApi::handleTrigger(const QJsonObject &body, const Reply &reply)
530535
const QString label = menuLabel(r.menuPath, ControlActionIndex::plainText(r.action->text()));
531536
if (!r.visible) {
532537
logAction(QStringLiteral("trigger"), id, label, false, QStringLiteral("not visible"));
533-
reply.error(409, QStringLiteral("'%1' is hidden; a user could not click it").arg(label));
538+
QString message = QStringLiteral("'%1' is hidden; a user could not click it").arg(label);
539+
const QString alternative = r.action->property(ids::kAlternativeProperty).toString();
540+
if (!alternative.isEmpty()) {
541+
const ControlActionIndex::Resolved alt = index.resolve(alternative);
542+
if (alt.action && alt.visible && alt.enabled)
543+
message += QStringLiteral(". '%1' (id %2) does the same")
544+
.arg(menuLabel(alt.menuPath, ControlActionIndex::plainText(alt.action->text())),
545+
alternative);
546+
}
547+
reply.error(409, message);
534548
return;
535549
}
536550
if (!r.enabled) {
@@ -545,6 +559,12 @@ void ControlApi::handleTrigger(const QJsonObject &body, const Reply &reply)
545559
reply.error(409, QStringLiteral("the modal dialog '%1' is open; answer it first").arg(ids::nodeId(modal).id));
546560
return;
547561
}
562+
// Nor while a menu is open (its exec() loop is on the stack): only its own items.
563+
if (QApplication::activePopupWidget() && !r.inPopup) {
564+
logAction(QStringLiteral("trigger"), id, label, false, QStringLiteral("a menu is open"));
565+
reply.error(409, QStringLiteral("a menu is open; pick from it, or close it with POST /ui/key {\"keys\": \"Escape\"}"));
566+
return;
567+
}
548568

549569
const bool hasValue = body.contains(QStringLiteral("value"));
550570
const QStringList values = hasValue ? pickerValues(body.value(QStringLiteral("value"))) : QStringList();
@@ -573,14 +593,59 @@ void ControlApi::handleTrigger(const QJsonObject &body, const Reply &reply)
573593

574594
if (!r.inPopup)
575595
m_feedback->flashAction(r.action);
576-
logAction(QStringLiteral("trigger"), id,
577-
hasValue ? label + QStringLiteral(" ← ") + values.join(QStringLiteral(", ")) : label);
596+
logAction(QStringLiteral("trigger"), id, label, true, QString(),
597+
hasValue ? QJsonObject{{QStringLiteral("value"), QJsonArray::fromStringList(values)}} : QJsonObject());
578598
runCommand(reply, std::move(act), {{QStringLiteral("action"), id}, {QStringLiteral("label"), label}},
579599
hasValue ? &values : nullptr);
580600
}
581601

582602
// --- /ui ---------------------------------------------------------------------
583603

604+
namespace {
605+
606+
// A log line's copy of a string: bounded, so a pasted file cannot fill the log.
607+
QJsonValue loggedValue(const QJsonValue &v)
608+
{
609+
constexpr qsizetype kMaxChars = 120;
610+
if (v.isString()) {
611+
const QString s = v.toString();
612+
return s.size() > kMaxChars ? QJsonValue(s.left(kMaxChars) + QStringLiteral("…")) : v;
613+
}
614+
if (v.isArray()) {
615+
QJsonArray out;
616+
const QJsonArray array = v.toArray();
617+
for (const auto &item : array)
618+
out.append(loggedValue(item));
619+
return out;
620+
}
621+
if (v.isObject()) {
622+
QJsonObject out;
623+
const QJsonObject o = v.toObject();
624+
for (auto it = o.constBegin(); it != o.constEnd(); ++it)
625+
out.insert(it.key(), loggedValue(it.value()));
626+
return out;
627+
}
628+
return v;
629+
}
630+
631+
// What the step was given (the body minus its id), as the log keeps it.
632+
// Never what goes into a password field.
633+
QJsonObject loggedArgs(const QJsonObject &body, const QWidget *target)
634+
{
635+
QJsonObject args;
636+
const auto *edit = qobject_cast<const QLineEdit *>(target);
637+
const bool secret = edit && edit->echoMode() != QLineEdit::Normal;
638+
for (auto it = body.constBegin(); it != body.constEnd(); ++it) {
639+
if (it.key() == QLatin1String("id"))
640+
continue;
641+
const bool typed = it.key() == QLatin1String("text") || it.key() == QLatin1String("value");
642+
args.insert(it.key(), secret && typed ? QJsonValue(QStringLiteral("••••")) : loggedValue(it.value()));
643+
}
644+
return args;
645+
}
646+
647+
} // namespace
648+
584649
void ControlApi::uiCommand(const QJsonObject &body, const Reply &reply, const QString &op, bool needsTarget,
585650
const std::function<QString(const ui::Target &)> &fn)
586651
{
@@ -594,7 +659,7 @@ void ControlApi::uiCommand(const QJsonObject &body, const Reply &reply, const QS
594659
}
595660
const QString blocked = ui::blockedReason(target, op);
596661
if (!blocked.isEmpty()) {
597-
logAction(op, id, ui::labelOf(target.widget), false, blocked);
662+
logAction(op, id, ui::labelOf(target.widget), false, blocked, loggedArgs(body, target.widget));
598663
reply.error(409, blocked);
599664
return;
600665
}
@@ -606,20 +671,9 @@ void ControlApi::uiCommand(const QJsonObject &body, const Reply &reply, const QS
606671
QString label = target.widget ? ui::labelOf(target.widget) : QString();
607672
if (!target.part.isEmpty())
608673
label += QStringLiteral(" @ ") + target.part;
609-
QString detail;
610-
if (body.contains(QStringLiteral("text")) || body.contains(QStringLiteral("value"))) {
611-
const QJsonValue v = body.contains(QStringLiteral("text")) ? body.value(QStringLiteral("text"))
612-
: body.value(QStringLiteral("value"));
613-
auto *edit = target.widget ? qobject_cast<QLineEdit *>(target.widget) : nullptr;
614-
// Never echo what goes into a password field.
615-
detail = edit && edit->echoMode() != QLineEdit::Normal ? QStringLiteral("••••")
616-
: v.toVariant().toString().left(120);
617-
} else if (body.contains(QStringLiteral("keys"))) {
618-
detail = body.value(QStringLiteral("keys")).toVariant().toString();
619-
}
620674
if (target.widget)
621675
m_feedback->flash(target.widget, ui::targetRect(target));
622-
logAction(op, id, detail.isEmpty() ? label : label + QStringLiteral(" ← ") + detail);
676+
logAction(op, id, label, true, QString(), loggedArgs(body, target.widget));
623677

624678
QPointer<QWidget> widget = target.widget;
625679
const QString part = target.part;
@@ -714,7 +768,7 @@ void ControlApi::registerUiRoutes()
714768
const QJsonValue keys = body.value(QStringLiteral("keys"));
715769
if (keys.isArray()) {
716770
const QJsonArray array = keys.toArray();
717-
for (const QJsonValue &k : array)
771+
for (const auto &k : array)
718772
sequences.append(k.toString());
719773
} else {
720774
sequences.append(keys.toString());
@@ -770,7 +824,7 @@ bool shownTextContains(const QJsonValue &v, const QString &needle, const QString
770824
return key != QLatin1String("id") && key != QLatin1String("key") && v.toString().contains(needle);
771825
if (v.isArray()) {
772826
const QJsonArray a = v.toArray();
773-
for (const QJsonValue &e : a) {
827+
for (const auto &e : a) {
774828
if (shownTextContains(e, needle, key))
775829
return true;
776830
}
@@ -824,6 +878,7 @@ bool eventMatches(const QString &pattern, const QJsonObject &match, const QStrin
824878
return true;
825879
}
826880

881+
// NOLINTNEXTLINE(bugprone-throwing-static-initialization): a QStringList table cannot have noexcept dynamic init.
827882
const QStringList kStates{QStringLiteral("exists"), QStringLiteral("gone"), QStringLiteral("visible"),
828883
QStringLiteral("hidden"), QStringLiteral("enabled"), QStringLiteral("disabled"),
829884
QStringLiteral("idle"), QStringLiteral("waitingForUser")};
@@ -839,7 +894,8 @@ void ControlApi::registerEventRoutes()
839894
const QString id = body.value(QStringLiteral("id")).toString();
840895
QStringList states;
841896
if (const QJsonValue sv = body.value(QStringLiteral("state")); sv.isArray()) {
842-
for (const QJsonValue &v : sv.toArray())
897+
const QJsonArray list = sv.toArray();
898+
for (const auto &v : list)
843899
states.append(v.toString());
844900
} else if (!sv.toString().isEmpty()) {
845901
states.append(sv.toString());

‎src/control/ControlApi.h‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class ControlApi : public QObject
6565

6666
public:
6767
static constexpr int kApiVersion = 1;
68-
static constexpr int kSkillVersion = 1;
68+
static constexpr int kSkillVersion = 3;
6969
static constexpr quint16 kDefaultPort = 47821;
7070
// The keychain entry holding the token.
7171
static constexpr char kTokenSecretKey[] = "control-api-token";
@@ -126,7 +126,7 @@ class ControlApi : public QObject
126126
QString pickerHint(const QString &id) const { return m_pickers.value(id); }
127127
// Logs the step, flashes nothing (callers flash what they touched).
128128
void logAction(const QString &op, const QString &target, const QString &label, bool ok = true,
129-
const QString &detail = QString());
129+
const QString &detail = QString(), const QJsonObject &args = {});
130130

131131
// Runs `act` as a command: answers with {ok, completed, effects} once it
132132
// finished or a modal dialog / popup took over. With `pickerValues`, the

‎src/control/ControlAudit.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ bool isPartOfAnotherControl(const QWidget *w)
4242
{
4343
if (qstrncmp(w->metaObject()->className(), "ads::", 5) == 0)
4444
return true;
45+
// QMainWindow's own bar over tabbed panels: driven through the panels.
46+
if (qobject_cast<const QTabBar *>(w) && qobject_cast<const QMainWindow *>(w->parentWidget()))
47+
return true;
4548
for (const QWidget *p = w->parentWidget(); p; p = p->parentWidget()) {
4649
if (ids::isIgnored(p))
4750
return true;

‎src/control/ControlEvents.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ void ControlEvents::setSubscribers(int count)
7373
quint64 ControlEvents::addWaiter(Waiter waiter)
7474
{
7575
const quint64 id = m_nextWaiter++;
76-
m_waiters.insert(id, std::move(waiter));
76+
m_waiters.emplace(id, std::move(waiter));
7777
updateActive();
7878
return id;
7979
}

‎src/control/ControlHttp.h‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
#include <QPair>
1818
#include <QString>
1919

20+
#include <cstdint>
21+
2022
// Minimal HTTP/1.1 for the Control API: Content-Length bodies only, keep-alive,
2123
// pipelining. Pure, no sockets.
2224
namespace control::http {
@@ -40,7 +42,7 @@ struct Request
4042
class RequestParser
4143
{
4244
public:
43-
enum class State { NeedMore, Complete, Error };
45+
enum class State : std::uint8_t { NeedMore, Complete, Error };
4446

4547
static constexpr qsizetype kMaxHeadBytes = 16 * 1024;
4648
static constexpr qsizetype kMaxBodyBytes = 4 * 1024 * 1024;

0 commit comments

Comments
 (0)