v0.8.8: Qt front end redesign, responsive shell, UI input tests

The window is no longer one file pretending to be a chat box. The transcript,
the run panel, the composer and the two consent dialogs are their own QML
components over one Theme singleton, and the shell around them adapts to the
screen: docked and scrollable on a desktop, a slide-out drawer on a phone.

QML (app/qml/, new except Main.qml):
- Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface,
  border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace);
  diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget
  34, segmentHeight 30, headerHeight 42.
- ChatView.qml: a row-type dispatcher over the transcript model (message,
  thought, tool, note), follow-the-tail scrolling with a "jump to latest"
  button once the user scrolls up, and an empty state that says where the
  agent runs and what it can do.
- MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill,
  arguments, collapsible output, 500-line cut, diff colouring, spinner while
  the call runs), TodoPanel.qml.
- Sidebar.qml: workspace/provider/status, mode and approval segmented rows,
  plan switch, the engine's todo list, version footer; scrolls, so a short
  window still reaches every control.
- Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines,
  red Stop while a turn runs, autocorrect hints off, finger-sized targets.
- ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml
  (the model's options become buttons, free text stays possible).
- Main.qml: shell with header, draggable sidebar splitter (hover feedback,
  200-560 px, double-click resets), transcript, composer, the two dialogs,
  Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a
  drawer with a scrim instead of squeezing the chat away.

AgentCore / main.cpp:
- provider, model, approval and planMode are properties now; approval and
  plan mode write back into the engine's config, and renderMarkdown() calls
  cst_web_md_html(), so a table or a code fence looks like the WebUI's.
- the todo hook writes m_todo on the GUI thread (it was written from the
  worker thread before: a data race on a QString member).
- --size WxH for the window (the layout tests drive it), and the Basic style
  is chosen by default: the Breeze Quick style registers its types with
  relative URLs, which floods the log and breaks the composer.

UI tests (app/src/ui/):
- --uitest drives the real window with synthesized input and exits with the
  number of failures: 15 checks over docking/drawer transitions, mouse clicks
  and touch taps reaching the engine's mode, the plan switch, the composer
  arming the send button, and a 460x900 -> 1200x820 resize round trip.
- every control the tests touch carries an objectName; the sidebar's
  implicitHeight binding loop is gone.

Tests: cst-app-selftest and --uitest pass in the standalone app build,
ctest cst-app-selftest passes in the repo build (38/38 on the host).

Version 0.8.8.
This commit is contained in:
WHC
2026-10-11 11:58:23 +08:00
parent 5ba215b36c
commit da812cc7e8
20 changed files with 1874 additions and 179 deletions
+25 -1
View File
@@ -5,6 +5,10 @@
#include <QFileInfo>
#include <QThread>
#include <cstdlib>
#include "cst_web_md.h"
/* --- the worker thread --------------------------------------------------- */
/* One turn: the whole cst_agent_run_turn_ex() call, on its own thread. The
@@ -80,9 +84,23 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
}
/* The session file is created by the engine; attachments live next to it. */
emit workspaceChanged();
emit configChanged();
return true;
}
/* The engine already renders markdown for the WebUI; the window uses the same
* renderer, so a table or a code fence looks the same in both front ends. */
QString AgentCore::renderMarkdown(const QString &text) const {
const QByteArray in = text.toUtf8();
char *html = cst_web_md_html(in.constData(), static_cast<size_t>(in.size()));
if (!html)
return text.toHtmlEscaped().replace(QLatin1Char('\n'),
QStringLiteral("<br>"));
const QString out = QString::fromUtf8(html);
free(html);
return out;
}
void AgentCore::close() {
if (!m_agent)
return;
@@ -215,12 +233,14 @@ void AgentCore::setApproval(int approval) {
m_cfg.approval = approval;
if (m_agent)
cst_agent_config_mut(m_agent)->approval = approval;
emit approvalChanged();
}
void AgentCore::setPlanMode(bool on) {
m_cfg.plan_mode = on ? 1 : 0;
if (m_agent)
cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode;
emit planModeChanged();
}
/* --- tools the front end provides ---------------------------------------- */
@@ -279,5 +299,9 @@ void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
void AgentCore::hookTodo(const char *text, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(text ? text : "");
self->post([self, t] { emit self->todoChanged(t); });
/* Runs on the worker thread: touch m_todo on the GUI thread only. */
self->post([self, t] {
self->m_todo = t;
emit self->todoChanged(t);
});
}
+18
View File
@@ -24,6 +24,11 @@ class AgentCore : public QObject {
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
Q_PROPERTY(QString model READ model NOTIFY configChanged)
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
public:
explicit AgentCore(QObject *parent = nullptr);
@@ -38,6 +43,15 @@ class AgentCore : public QObject {
bool running() const { return m_running; }
QString workspace() const { return m_workspace; }
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
QString model() const { return QString::fromUtf8(m_cfg.model); }
int approval() const { return m_cfg.approval; }
bool planMode() const { return m_cfg.plan_mode != 0; }
QString todo() const { return m_todo; }
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
* message looks the same here as it does in the WebUI. */
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
/* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; }
@@ -68,6 +82,9 @@ class AgentCore : public QObject {
void setAutoApprove(bool on) { m_autoApprove = on; }
signals:
void configChanged();
void approvalChanged();
void planModeChanged();
void statusChanged(const QString &text);
void assistantDelta(const QString &text);
void thinkDelta(const QString &text);
@@ -121,6 +138,7 @@ class AgentCore : public QObject {
int m_permAnswer = CST_PERM_DENY;
bool m_permPending = false;
QString m_question;
QString m_todo;
QByteArray m_answer;
bool m_questionPending = false;
bool m_autoApprove = false;
+40 -1
View File
@@ -1,6 +1,7 @@
/* cst-agent-app: the Qt front end.
*
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
* cst-agent-app --selftest embedding checks, no window, no network
*
* The engine runs in this process (see app/README.md); nothing listens on a
@@ -13,6 +14,7 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTextStream>
@@ -23,6 +25,7 @@
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
#include "ui/UiTest.h"
#include "cst_version.h"
namespace {
@@ -97,6 +100,11 @@ int main(int argc, char **argv) {
}
QGuiApplication app(argc, argv);
/* The window styles its own controls; the platform style only adds noise
* here (the KDE Breeze plugin logs a line per registered type and trips
* over the composer's TextArea). An explicit choice still wins. */
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
QQuickStyle::setStyle(QStringLiteral("Basic"));
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
@@ -116,10 +124,18 @@ int main(int argc, char **argv) {
QStringLiteral("model name"), QStringLiteral("name"));
QCommandLineOption planOpt(QStringLiteral("plan"),
QStringLiteral("start in plan mode"));
QCommandLineOption uitestOpt(
QStringLiteral("uitest"),
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
QCommandLineOption sizeOpt(QStringLiteral("size"),
QStringLiteral("initial window size, e.g. 480x900"),
QStringLiteral("WxH"));
parser.addOption(wsOpt);
parser.addOption(providerOpt);
parser.addOption(modelOpt);
parser.addOption(planOpt);
parser.addOption(uitestOpt);
parser.addOption(sizeOpt);
parser.process(app);
QString ws = parser.value(wsOpt);
@@ -138,9 +154,32 @@ int main(int argc, char **argv) {
if (parser.isSet(planOpt))
core.setPlanMode(true);
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
if (parser.isSet(uitestOpt))
return app::runUiTest(core, parser.value(sizeOpt));
/* The layout is responsive; --size is how it gets exercised without a
* different screen (see app/README.md). */
int winW = 0, winH = 0;
const QString size = parser.value(sizeOpt);
if (!size.isEmpty()) {
const int x = size.indexOf(QLatin1Char('x'));
bool okW = false, okH = false;
if (x > 0) {
winW = size.left(x).toInt(&okW);
winH = size.mid(x + 1).toInt(&okH);
}
if (!okW || !okH || winW < 320 || winH < 360) {
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
return 2;
}
}
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/qml/Main.qml"));
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
QObject::connect(
&engine, &QQmlApplicationEngine::objectCreated, &app,
[url](QObject *obj, const QUrl &objUrl) {
+165
View File
@@ -0,0 +1,165 @@
/* --uitest: drive the window with synthesized input.
*
* The GUI is checked the way a person uses it -- a mouse click on a segment,
* a finger tap on another, a narrowed window that has to collapse the
* sidebar -- instead of only loading the QML and hoping. Qt's test event
* synthesis does the pointing, so this runs on any platform, with or without
* a touchscreen, and can be scripted (see app/README.md). */
#include "UiTest.h"
#include <QPointingDevice>
#include <QQuickItem>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTest>
#include <cstdio>
namespace app {
namespace {
int g_fail;
void check(bool ok, const QString &what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
std::fflush(stdout);
if (!ok)
g_fail++;
}
QQuickItem *item(QQuickWindow *win, const QString &name) {
return win->findChild<QQuickItem *>(name);
}
/* The centre of an item, in window coordinates. */
QPoint centre(QQuickItem *it) {
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
return p.toPoint();
}
void clickMouse(QQuickWindow *win, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("mouse click %1: not there").arg(name));
return;
}
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
}
void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) {
check(false, QStringLiteral("touch tap %1: not there").arg(name));
return;
}
const QPoint p = centre(it);
QTest::touchEvent(win, dev).press(0, p, win);
QTest::touchEvent(win, dev).release(0, p, win);
}
void resizeTo(QQuickWindow *win, int w, int h) {
win->resize(w, h);
QTest::qWait(250); /* let the layouts settle */
}
} // namespace
int runUiTest(AgentCore &core, const QString &size) {
int width = 1000, height = 720;
const int x = size.indexOf(QLatin1Char('x'));
if (x > 0) {
const int w = size.left(x).toInt(), h = size.mid(x + 1).toInt();
if (w > 0 && h > 0) {
width = w;
height = h;
}
}
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), width);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height);
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml")));
if (engine.rootObjects().isEmpty()) {
std::printf("FAIL the window did not load\n");
return 1;
}
QQuickWindow *win = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
if (!win) {
std::printf("FAIL the root object is not a window\n");
return 1;
}
QTest::qWait(250);
QQuickItem *docked = item(win, QStringLiteral("dockedSidebar"));
QQuickItem *navButton = item(win, QStringLiteral("navButton"));
QQuickItem *navPanel = item(win, QStringLiteral("navPanel"));
QQuickItem *composer = item(win, QStringLiteral("composerInput"));
QPointingDevice *touch = QTest::createTouchDevice();
/* --- a desktop window: the sidebar is docked, the drawer is gone --- */
check(docked && docked->isVisible(), QStringLiteral("wide window: sidebar docked"));
check(navButton && !navButton->isVisible(),
QStringLiteral("wide window: no drawer button"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("engine starts in coding mode"));
/* --- mouse: pick the other mode --- */
clickMouse(win, QStringLiteral("modeGeneral"));
check(core.mode() == QLatin1String("general"),
QStringLiteral("mouse click on \"general\" reaches the engine"));
check(item(win, QStringLiteral("modeGeneral"))->property("current").toBool(),
QStringLiteral("mouse click marks \"general\" as current"));
/* --- touch: tap the first mode back --- */
tapTouch(win, touch, QStringLiteral("modeCoding"));
check(core.mode() == QLatin1String("coding"),
QStringLiteral("touch tap on \"coding\" reaches the engine"));
/* --- mouse: the plan switch is a two-way control --- */
clickMouse(win, QStringLiteral("planSwitch"));
check(core.planMode(), QStringLiteral("plan switch turns plan mode on"));
clickMouse(win, QStringLiteral("planSwitch"));
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
/* --- keyboard: typing in the composer arms Send --- */
QQuickItem *send = item(win, QStringLiteral("sendButton"));
if (composer && send) {
composer->forceActiveFocus();
/* QTest::keyClicks() is QWidget-only; a window takes one key at a time. */
for (const QChar &c : QStringLiteral("hello"))
QTest::keyClick(win, c.toLatin1());
check(send->property("enabled").toBool(),
QStringLiteral("typing arms the send button"));
QTest::keyClick(win, Qt::Key_A, Qt::ControlModifier);
QTest::keyClick(win, Qt::Key_Backspace);
check(!send->property("enabled").toBool(),
QStringLiteral("an empty composer disarms it"));
} else {
check(false, QStringLiteral("composer and send button are reachable"));
}
/* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900);
check(docked && !docked->isVisible(),
QStringLiteral("narrow window: the docked sidebar folds away"));
check(navButton && navButton->isVisible(),
QStringLiteral("narrow window: the drawer button appears"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("drawer opens over the transcript"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && !navPanel->isVisible(),
QStringLiteral("drawer closes again"));
resizeTo(win, 1200, 820);
check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back"));
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
return g_fail ? 1 : 0;
}
} // namespace app
+20
View File
@@ -0,0 +1,20 @@
/* The --uitest entry point: see UiTest.cpp for what it drives. */
#ifndef CST_APP_UITEST_H
#define CST_APP_UITEST_H
#include <QString>
#include "core/AgentCore.h"
namespace app {
/* Loads the window, drives its controls with synthesized mouse/touch input
* (and a resize to the compact layout), prints one line per check and returns
* how many failed as the process exit code. `size` is the usual WxH string,
* empty for the default. */
int runUiTest(AgentCore &core, const QString &size);
} // namespace app
#endif /* CST_APP_UITEST_H */