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
+27 -3
View File
@@ -64,7 +64,7 @@ endif()
# --- the GUI ------------------------------------------------------------- # --- the GUI -------------------------------------------------------------
if(CST_APP_GUI) if(CST_APP_GUI)
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 QUIET) find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
if(NOT Qt6_FOUND) if(NOT Qt6_FOUND)
message(WARNING message(WARNING
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app " "app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
@@ -80,6 +80,7 @@ if(CST_APP_GUI)
src/main.cpp src/main.cpp
src/core/AgentCore.cpp src/core/AgentCore.cpp
src/core/ToolProvider.cpp src/core/ToolProvider.cpp
src/ui/UiTest.cpp
src/platform/linux/NotifyTool.cpp) src/platform/linux/NotifyTool.cpp)
target_include_directories(cst-agent-app PRIVATE target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include") src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
@@ -89,14 +90,37 @@ if(CST_APP_GUI)
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}") CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
endif() endif()
target_link_libraries(cst-agent-app PRIVATE target_link_libraries(cst-agent-app PRIVATE
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 cstcore) Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
if(WIN32) if(WIN32)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif() endif()
# Theme is read by every component, so it is a QML singleton (one instance,
# no object needed in the tree).
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
# Sources live in qml/, but a module whose files sit in a subdirectory of
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
# requires absolute URLs" on every start).
foreach(_qml Main ChatView Sidebar MessageCard ToolCard ThinkBlock NoteLine
TodoPanel Composer ConsentDialog AskDialog Theme)
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
endforeach()
qt_add_qml_module(cst-agent-app qt_add_qml_module(cst-agent-app
URI CstAgent URI CstAgent
VERSION 1.0 VERSION 1.0
QML_FILES qml/Main.qml) QML_FILES
qml/Main.qml
qml/ChatView.qml
qml/Sidebar.qml
qml/MessageCard.qml
qml/ToolCard.qml
qml/ThinkBlock.qml
qml/NoteLine.qml
qml/TodoPanel.qml
qml/Composer.qml
qml/ConsentDialog.qml
qml/AskDialog.qml
qml/Theme.qml)
endif() endif()
endif() endif()
+36 -2
View File
@@ -24,6 +24,8 @@ QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
# the app alone # the app alone
./build-app/cst-agent-app --workspace ~/notes ./build-app/cst-agent-app --workspace ~/notes
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
./build-app/cst-agent-app --selftest # same checks as the C test ./build-app/cst-agent-app --selftest # same checks as the C test
``` ```
@@ -61,6 +63,35 @@ source.
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal - `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
result, not an exception. result, not an exception.
## Layout and input
One shell, three sizes (everything is styled from `qml/Theme.qml`):
- **Desktop**: the sidebar is docked and its divider can be dragged (double
click restores the auto width). Hover states, tooltips, selectable text and
a wheel-scrolled transcript.
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
folds away, the header grows a `☰` button, and the settings slide in over
the transcript with a scrim; the sidebar itself scrolls, because a phone
window cannot show it all.
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
not depend on hover, the composer asks the platform keyboard not to
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
`--uitest` checks this without a human: it loads the window, clicks with
synthesized **mouse** events, taps with a synthesized **touch** device, types
into the composer, then narrows the window to 460 px to assert the dock →
drawer switch and opens/closes the drawer. One line per check, non-zero exit
on failure:
```sh
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
# ok wide window: sidebar docked
# ok mouse click on "general" reaches the engine
# ok touch tap on "coding" reaches the engine
# ...
```
## Tools the app provides ## Tools the app provides
A tool implemented in the front end is registered with `cst_tool_register()` A tool implemented in the front end is registered with `cst_tool_register()`
@@ -113,7 +144,10 @@ The build flag that keeps Unix helpers out of the picture is
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API - [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule - [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test - [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
- [ ] QML window against a real provider (needs Qt Quick) - [x] QML shell: transcript cards, tool cards with diff coloring, thinking
- [ ] session list / switch, attachments, diff viewer, settings page blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
`--uitest` for mouse, touch and small-screen checks
- [ ] a real turn driven through the window (needs a provider key)
- [ ] session list / switch, attachments, settings page
- [ ] Kirigami (Breeze look), tray, global shortcuts - [ ] Kirigami (Breeze look), tray, global shortcuts
- [ ] Android APK (engine in-process, JNI platform tools) - [ ] Android APK (engine in-process, JNI platform tools)
+108
View File
@@ -0,0 +1,108 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// on_ask_user: the agent stopped and wants an answer from the human. Options
// (when the engine sends any) become buttons; the field is always there.
Dialog {
id: dialog
property string question: ""
property string optionText: ""
signal answered(string text)
readonly property var options: {
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
return raw.length > 1 && raw.length <= 8 ? raw : []
}
modal: true
anchors.centerIn: parent
width: Math.min(620, parent ? parent.width - 60 : 620)
padding: Theme.pad
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
function send(text) {
const t = text.trim()
if (!t.length)
return
answered(t)
close()
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "The agent asks"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
}
Text {
Layout.fillWidth: true
text: dialog.question
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: dialog.optionText.length > 0 && dialog.options.length === 0
text: dialog.optionText
color: Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
Flow {
Layout.fillWidth: true
visible: dialog.options.length > 0
spacing: Theme.gap
Repeater {
model: dialog.options
Button {
text: modelData
onClicked: dialog.send(modelData)
}
}
}
TextField {
id: answer
Layout.fillWidth: true
placeholderText: "your answer"
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
selectByMouse: true
onAccepted: dialog.send(text)
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
}
}
}
footer: DialogButtonBox {
padding: Theme.pad
Button {
text: "Send"
highlighted: true
enabled: answer.text.trim().length > 0
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: dialog.send(answer.text)
}
}
onOpened: answer.forceActiveFocus()
}
+134
View File
@@ -0,0 +1,134 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The transcript: cards for the messages, a card per tool call, folded
// thinking blocks and one-line notes. Follows the tail of the conversation
// until the user scrolls away from it.
Item {
id: view
property var model: null
property bool running: false
ListView {
id: list
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
model: view.model
clip: true
spacing: Theme.gap
topMargin: Theme.gapL
bottomMargin: Theme.gapL
cacheBuffer: 2000
boundsBehavior: Flickable.StopAtBounds
// The delegate picks the card by row kind; the components below read
// the ListModel roles directly, which is why they are declared in the
// delegate's scope.
delegate: Loader {
id: rowLoader
width: list.width
sourceComponent: model.kind === "tool" ? toolComp
: model.kind === "think" ? thinkComp
: model.kind === "note" ? noteComp : msgComp
Component {
id: msgComp
MessageCard {
role: model.role
text: model.text
complete: model.complete
html: (model.role === "agent" || model.role === "error") &&
(model.complete || model.text.length > 12000) &&
model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
Component {
id: thinkComp
ThinkBlock {
text: model.text
complete: model.complete
}
}
Component {
id: toolComp
ToolCard {
name: model.name
state: model.state
detail: model.detail
output: model.output
exitCode: model.exitCode
hasResult: model.complete
}
}
Component {
id: noteComp
NoteLine {
text: model.text
level: model.state === "warn" || model.state === "error" ? model.state : "info"
}
}
}
onMovementEnded: view.stickBottom = atYEnd
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
}
// Follow the tail unless the user asked otherwise.
property bool stickBottom: true
// Empty state: what this window is and what to do with it.
ColumnLayout {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 520)
spacing: Theme.gap
visible: view.model ? view.model.count === 0 : true
Text {
Layout.alignment: Qt.AlignHCenter
text: "CST-agent"
color: Theme.text
font.pixelSize: 22
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "The engine runs in this window — no server, no second process."
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Text {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.gap
text: "Ask for a change, a command to run, a file to explain.\n" +
"Every write, delete and command asks first: Deny / Once / Always.\n" +
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
// Jump back to the live end of the conversation.
RoundButton {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.padL
width: Theme.jumpButton
height: Theme.jumpButton
visible: !view.stickBottom && list.contentHeight > list.height
text: "↓"
onClicked: {
view.stickBottom = true
list.positionViewAtEnd()
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The input line: multiline, Enter sends, the button changes with the turn
// state. Sized for a thumb as much as for a mouse, and it asks the platform
// keyboard not to autocorrect what is usually code or a path.
Rectangle {
id: root
objectName: "composer"
property bool running: false
property bool compact: false
property alias text: input.text
signal submitted(string text)
function submit() {
const t = input.text.trim()
if (!t.length || running)
return
input.text = ""
submitted(t)
}
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: input.activeFocus ? Theme.accentDim : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextArea {
id: input
objectName: "composerInput"
Layout.fillWidth: true
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12))
placeholderText: root.running
? "the turn is running — Esc stops it"
: (root.compact
? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)")
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: TextArea.Wrap
selectByMouse: true
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" }
enabled: !root.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
if (!(ev.modifiers & Qt.ShiftModifier)) {
root.submit()
ev.accepted = true
}
}
}
}
Button {
id: action
objectName: "sendButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4
text: root.running ? "Stop" : "Send"
enabled: root.running || input.text.trim().length > 0
onClicked: root.running ? agent.cancel() : root.submit()
background: Rectangle {
radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt
: root.running ? (action.hovered ? "#e05666" : Theme.err)
: (action.hovered ? "#5cc0f0" : Theme.accent)
}
contentItem: Text {
text: action.text
color: action.enabled ? "#12161a" : Theme.textFaint
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// On a narrow window the hint would wrap into the editor's space.
Text {
Layout.fillWidth: true
visible: !root.compact
text: root.running
? "working — Esc cancels the turn"
: "Enter sends · Shift+Enter newline · Esc stops · every change asks first"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's on_permission hook is blocked on this dialog: nothing runs
// until one of the three answers goes back.
Dialog {
id: dialog
property string tool: ""
property string detail: ""
property string preview: ""
signal answered(int choice)
modal: true
anchors.centerIn: parent
width: Math.min(760, parent ? parent.width - 60 : 760)
padding: Theme.pad
title: ""
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 8
height: 8
radius: 4
color: Theme.warn
}
Text {
text: "Permission requested"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: dialog.tool
color: Theme.warn
font.family: Theme.mono
font.pixelSize: Theme.monoSize
}
}
Text {
Layout.fillWidth: true
visible: dialog.detail.length > 0
text: dialog.detail
color: Theme.textDim
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
visible: dialog.preview.length > 0
color: Theme.bg
radius: Theme.radiusSm
border.width: 1
border.color: Theme.border
Flickable {
id: flick
anchors.fill: parent
anchors.margins: 8
contentWidth: previewText.width
contentHeight: previewText.height
clip: true
ScrollBar.vertical: ScrollBar {}
Text {
id: previewText
width: Math.max(flick.width, implicitWidth)
text: dialog.preview
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
textFormat: Text.PlainText
}
}
}
Text {
Layout.fillWidth: true
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
footer: DialogButtonBox {
spacing: Theme.gap
padding: Theme.pad
Button {
text: "Deny"
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: { dialog.answered(0); dialog.close() }
}
Button {
text: "Once"
highlighted: true
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: { dialog.answered(1); dialog.close() }
}
Button {
text: "Always"
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
onClicked: { dialog.answered(2); dialog.close() }
}
}
}
+347 -171
View File
@@ -1,9 +1,11 @@
// The app window: chat on the left, tool runs and consent on top of it. // The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
// //
// Qt Quick Controls only, laid out the way Kirigami expects (single column, // One shell, three sizes: the sidebar is docked on a desktop (and can be
// cards, drawer for navigation) so that swapping `import QtQuick.Controls` // dragged), slides over the transcript when the window is narrow, and every
// for `import org.kde.kirigami as Kirigami` and Page/Card wrappers is a QML // control keeps a finger-sized hit area for touch screens. Qt Quick Controls
// change, not a rewrite. // only, laid out the way Kirigami expects (sidebar + page, cards, header), so
// swapping in org.kde.kirigami later stays a QML change.
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
@@ -11,219 +13,393 @@ import QtQuick.Layouts
ApplicationWindow { ApplicationWindow {
id: win id: win
width: 980 width: initialWidth > 0 ? initialWidth : 1180
height: 720 height: initialHeight > 0 ? initialHeight : 800
minimumWidth: 360
minimumHeight: 420
visible: true visible: true
color: Theme.bg
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace") title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
property string statusText: ""
property bool navOpen: false // the sliding sidebar, compact only
property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0
? sidebarWidth
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
// The settings panel, used docked and in the sliding panel: one definition,
// two places.
component ShellSidebar: Sidebar {
workspace: agent.workspace
provider: agent.provider
model: agent.model
mode: agent.mode
approval: agent.approval
planMode: agent.planMode
running: agent.running
status: win.statusText
todo: agent.todo
onModeRequested: function (m) { agent.setMode(m) }
onApprovalRequested: function (a) { agent.setApproval(a) }
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
}
// ---- the transcript --------------------------------------------------
ListModel { id: transcript } ListModel { id: transcript }
function lastIndex() { return transcript.count - 1 }
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", complete: false,
name: "", state: state, detail: "", output: "", exitCode: 0 })
}
// The engine streams one kind at a time: close whatever was open before.
function closeOpenRows(exceptKind) {
const last = lastRow()
if (last && !last.complete && last.kind !== exceptKind)
transcript.setProperty(lastIndex(), "complete", true)
}
function appendDelta(kind, role, text) {
closeOpenRows(kind)
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
addRow(kind, role, "running")
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The last tool row with this name that is still open, -1 when none.
function findTool(name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool")
continue
if (!r.complete && r.name === name)
return i
if (r.complete)
return -1
}
return -1
}
function openTool(name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, complete: true,
name: "", state: "", detail: "", output: "", exitCode: 0 })
agent.send(text)
}
// ---- the shell -------------------------------------------------------
header: ToolBar { header: ToolBar {
id: appBar
implicitHeight: Theme.headerHeight
background: Rectangle {
color: Theme.surface
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.border
}
}
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
Label { anchors.leftMargin: Theme.pad
text: agent.workspace.length ? agent.workspace : "(no workspace)" anchors.rightMargin: Theme.gap
elide: Text.ElideMiddle spacing: Theme.gap
// The docked sidebar has its own title: only the sliding one needs
// a way in.
ToolButton {
objectName: "navButton"
visible: win.compact
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered && !win.compact
ToolTip.text: "Settings and plan"
contentItem: Text {
text: "☰"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: 17
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.navOpen = !win.navOpen
}
Text {
visible: !win.compact
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideMiddle
text: agent.workspace.length ? agent.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
objectName: "statusText"
visible: win.statusText.length > 0 && !win.compact
text: win.statusText
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
Layout.maximumWidth: 260
} }
BusyIndicator { BusyIndicator {
running: agent.running running: agent.running
visible: agent.running visible: agent.running
implicitWidth: 24 implicitWidth: 22
implicitHeight: 24 implicitHeight: 22
} }
ComboBox { ToolButton {
id: modeBox objectName: "clearButton"
model: ["coding", "general"] enabled: transcript.count > 0 && !agent.running
currentIndex: agent.mode === "general" ? 1 : 0 implicitWidth: win.compact ? Theme.touchTarget : 56
onActivated: agent.setMode(model[currentIndex]) implicitHeight: Theme.touchTarget
} ToolTip.visible: hovered
} ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
} contentItem: Text {
text: "Clear"
// ---- consent: the engine's on_permission hook waits for this ---- color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
Dialog { font.pixelSize: Theme.smallSize
id: consent horizontalAlignment: Text.AlignHCenter
anchors.centerIn: parent verticalAlignment: Text.AlignVCenter
width: Math.min(720, win.width - 40) }
modal: true background: Rectangle {
property string tool: "" radius: Theme.radiusSm
property string detail: "" color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
property string preview: "" }
title: "Allow " + tool + "?" onClicked: {
contentItem: ColumnLayout { transcript.clear()
Label { text: consent.detail; wrapMode: Text.Wrap; Layout.fillWidth: true } win.statusText = ""
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(300, previewText.implicitHeight + 16)
visible: consent.preview.length > 0
TextArea {
id: previewText
text: consent.preview
readOnly: true
font.family: "monospace"
wrapMode: TextArea.NoWrap
} }
} }
} }
footer: DialogButtonBox {
Button { text: "Deny"; DialogButtonBox.buttonRole: DialogButtonBox.RejectRole }
Button { text: "Once"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
Button { text: "Always"; DialogButtonBox.buttonRole: DialogButtonBox.ActionRole }
onRejected: { agent.answerPermission(0); consent.close() }
onAccepted: { agent.answerPermission(1); consent.close() }
onClicked: function (button) {
if (button.text === "Always") { agent.answerPermission(2); consent.close() }
}
}
} }
// ---- ask_user ---- RowLayout {
Dialog {
id: question
anchors.centerIn: parent
width: Math.min(600, win.width - 40)
modal: true
property string text: ""
property string options: ""
title: "The agent asks"
contentItem: ColumnLayout {
Label { text: question.text; wrapMode: Text.Wrap; Layout.fillWidth: true }
Label {
visible: question.options.length > 0
text: question.options
wrapMode: Text.Wrap
Layout.fillWidth: true
opacity: 0.7
}
TextField { id: answerField; Layout.fillWidth: true; placeholderText: "your answer" }
}
footer: DialogButtonBox {
Button { text: "Send"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
onAccepted: { agent.answerQuestion(answerField.text); question.close() }
}
}
ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 12 spacing: 0
spacing: 8
ListView { ShellSidebar {
id: chat id: dockedSidebar
objectName: "dockedSidebar"
visible: !win.compact
Layout.fillHeight: true
Layout.preferredWidth: win.sidebarWide
}
// Drag the divider: a mouse affordance that costs nothing on touch
// (the handle is only there while the sidebar is docked).
Item {
id: divider
visible: !win.compact
Layout.fillHeight: true
implicitWidth: 5
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 1
height: parent.height
color: dividerMa.pressed ? Theme.accentDim
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
}
MouseArea {
id: dividerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SplitHCursor
property real startX: 0
property int startW: 0
onPressed: function (mouse) {
startX = mouse.x
startW = win.sidebarWide
}
onPositionChanged: function (mouse) {
if (pressed)
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
}
onDoubleClicked: win.sidebarWidth = 0
}
}
ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: transcript spacing: 0
clip: true
spacing: 8
delegate: Frame {
width: chat.width
background: Rectangle {
color: model.role === "user" ? "#2b579a22" : "#00000000"
border.color: "#88888833"
radius: 6
}
ColumnLayout {
width: parent.width
spacing: 2
Label {
text: model.role === "user" ? "You" : "Agent"
font.bold: true
opacity: 0.7
}
TextEdit {
text: model.text
readOnly: true
wrapMode: TextEdit.Wrap
selectByMouse: true
Layout.fillWidth: true
}
}
}
onCountChanged: positionViewAtEnd()
}
RowLayout { ChatView {
Layout.fillWidth: true id: chat
TextArea {
id: composer
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 90 Layout.fillHeight: true
placeholderText: agent.running ? "… working (Esc cancels)" model: transcript
: "Ask something (Enter sends, Shift+Enter for a new line)" running: agent.running
wrapMode: TextArea.Wrap
enabled: !agent.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return && !(ev.modifiers & Qt.ShiftModifier)) {
sendButton.clicked()
ev.accepted = true
}
}
} }
ColumnLayout {
Button { Composer {
id: sendButton Layout.fillWidth: true
text: "Send" Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
enabled: !agent.running && composer.text.trim().length > 0 Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
onClicked: { Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
transcript.append({ role: "user", text: composer.text }) running: agent.running
transcript.append({ role: "agent", text: "" }) compact: win.compact
agent.send(composer.text) onSubmitted: function (text) { win.submit(text) }
composer.text = ""
}
}
Button {
text: "Stop"
enabled: agent.running
onClicked: agent.cancel()
}
} }
} }
} }
Shortcut { sequence: "Escape"; onActivated: agent.cancel() } // ---- compact: the sidebar slides in over the transcript --------------
Rectangle {
// ---- engine -> UI ---- id: scrim
function appendToAgent(text) { objectName: "navScrim"
if (transcript.count === 0) visible: win.navOpen && win.compact
transcript.append({ role: "agent", text: "" }) anchors.fill: parent
const last = transcript.count - 1 color: "#000000"
transcript.setProperty(last, "text", transcript.get(last).text + text) opacity: 0.45
MouseArea {
anchors.fill: parent
onClicked: win.navOpen = false
onWheel: function (wheel) { wheel.accepted = true }
}
} }
Rectangle {
id: navPanel
objectName: "navPanel"
visible: win.navOpen && win.compact
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: Math.min(320, win.width * 0.88)
color: Theme.surface
border.width: 1
border.color: Theme.border
x: win.navOpen && win.compact ? 0 : -width
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
Flickable {
anchors.fill: parent
contentHeight: panelSidebar.implicitHeight
clip: true
ScrollBar.vertical: ScrollBar {}
ShellSidebar {
id: panelSidebar
width: parent.width
border.width: 0
}
}
}
ConsentDialog {
id: consent
onAnswered: function (choice) { agent.answerPermission(choice) }
}
AskDialog {
id: ask
onAnswered: function (text) { agent.answerQuestion(text) }
}
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
Shortcut {
sequence: "Ctrl+L"
enabled: !agent.running
onActivated: {
transcript.clear()
win.statusText = ""
}
}
// ---- engine -> UI ----------------------------------------------------
Connections { Connections {
target: agent target: agent
function onAssistantDelta(text) { appendToAgent(text) }
function onStatusChanged(text) { function onAssistantDelta(text) { win.appendDelta("message", "agent", text) }
transcript.append({ role: "agent", text: "· " + text }) function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
}
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, name, detail) { function onToolEvent(phase, name, detail) {
transcript.append({ win.closeOpenRows("tool")
role: "agent", let i = win.findTool(name)
text: "[" + name + " " + phase + (detail.length ? ": " + detail : "") + "]" if (phase === "open" || phase === "arg") {
}) if (i < 0)
i = win.openTool(name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
} }
function onToolResult(name, exitCode, output) { function onToolResult(name, exitCode, output) {
transcript.append({ let i = win.findTool(name)
role: "agent", if (i < 0)
text: "[" + name + " exit=" + exitCode + "]\n" + output i = transcript.count - 1
}) if (i < 0 || transcript.get(i).kind !== "tool")
return
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "complete", true)
} }
function onPermissionRequested(tool, detail, preview) { function onPermissionRequested(tool, detail, preview) {
consent.tool = tool consent.tool = tool
consent.detail = detail consent.detail = detail
consent.preview = preview consent.preview = preview
consent.open() consent.open()
} }
function onQuestionAsked(text, options) {
question.text = text function onQuestionAsked(question, options) {
question.options = options ask.question = question
answerField.text = "" ask.optionText = options
question.open() ask.open()
} }
function onTurnFinished(rc, err) { function onTurnFinished(rc, err) {
win.closeOpenRows("")
if (err.length) if (err.length)
transcript.append({ role: "agent", text: "error: " + err }) transcript.append({ kind: "note", role: "error", text: err, complete: true,
name: "", state: "error", detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
function onRunningChanged() {
if (!agent.running)
win.closeOpenRows("")
} }
} }
} }
+109
View File
@@ -0,0 +1,109 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One message in the transcript: the prompt, the answer, or an error. The
// answer arrives as plain text while it streams and is rendered from markdown
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
Rectangle {
id: card
property string role: "agent" // user | agent | error
property string text: ""
property string html: ""
property bool complete: true
radius: Theme.radius
color: role === "user" ? Theme.surfaceAlt : Theme.surface
border.width: 1
border.color: role === "error" ? Theme.err
: role === "user" ? Theme.border : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 6
height: 6
radius: 3
color: card.role === "user" ? Theme.accent
: card.role === "error" ? Theme.err : Theme.ok
}
Text {
text: card.role === "user" ? "You"
: card.role === "error" ? "Error" : "CST-agent"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
visible: !card.complete
text: "streaming…"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.text.length > 0
ToolTip.visible: hovered
ToolTip.text: "Copy"
contentItem: Text {
text: "⧉"
color: parent.hovered ? Theme.text : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: {
plain.text = card.text
plain.selectAll()
plain.copy()
}
}
}
// Hidden holder so "copy" always has the plain text, rtf or not.
TextEdit {
id: plain
visible: false
}
TextEdit {
id: streamText
visible: card.html.length === 0
Layout.fillWidth: true
text: card.text
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
color: card.role === "error" ? Theme.err : Theme.text
font.pixelSize: Theme.bodySize
selectionColor: Theme.accentDim
}
Text {
visible: card.html.length > 0
Layout.fillWidth: true
text: card.html
textFormat: Text.RichText
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import QtQuick
import QtQuick.Layouts
// A one-line note from the engine (status text, a refusal, a hint).
RowLayout {
id: note
property string text: ""
property string level: "info" // info | warn | error
spacing: Theme.gap
Rectangle {
Layout.alignment: Qt.AlignTop
Layout.topMargin: 6
width: 5
height: 5
radius: 3
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textFaint
}
Text {
Layout.fillWidth: true
text: note.text
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
+234
View File
@@ -0,0 +1,234 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// What the engine is set to: where it runs, how it frames itself, when it
// asks, whether it is planning. Everything here writes straight into the
// engine's config (AgentCore), never into a copy the engine cannot see.
//
// The panel scrolls (a phone-sized window cannot show it all) and every
// control keeps a finger-sized hit area.
Rectangle {
id: side
property string workspace: ""
property string provider: ""
property string model: ""
property string mode: "coding"
property int approval: 1 // cst_approval_t
property bool planMode: false
property bool running: false
property string status: ""
property string todo: ""
signal modeRequested(string mode)
signal approvalRequested(int approval)
signal planModeRequested(bool on)
readonly property alias contentColumn: col
color: Theme.surface
border.width: 1
border.color: Theme.border
implicitHeight: col.implicitHeight + Theme.pad * 2
// One selectable row of a segmented control.
component Seg: Button {
id: seg
property bool current: false
flat: true
implicitHeight: Theme.segmentHeight
padding: 0
background: Rectangle {
radius: Theme.radiusSm
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
border.width: 1
border.color: seg.current ? Theme.accent : Theme.border
}
contentItem: Text {
text: seg.text
color: seg.current ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight + Theme.pad * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: col
x: Theme.pad
y: Theme.pad
width: parent.width - Theme.pad * 2
spacing: Theme.gapL
// --- where ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
visible: side.running
text: "turn running"
color: Theme.warn
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
text: side.workspace.length ? side.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
ToolTip.visible: wsHover.hovered
ToolTip.text: side.workspace
HoverHandler { id: wsHover }
}
Text {
Layout.fillWidth: true
visible: side.provider.length > 0 || side.model.length > 0
text: (side.provider.length ? side.provider : "?") +
(side.model.length ? " · " + side.model : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: side.status.length > 0
text: side.status
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
// --- how it frames itself ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
objectName: "modeCoding"
Layout.fillWidth: true
text: "coding"
current: side.mode !== "general"
onClicked: side.modeRequested("coding")
}
Seg {
objectName: "modeGeneral"
Layout.fillWidth: true
text: "general"
current: side.mode === "general"
onClicked: side.modeRequested("general")
}
}
Text {
Layout.fillWidth: true
text: side.mode === "general" ? "documents, data, everyday files"
: "code framed: repos, builds, tests"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- when it asks ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
Layout.fillWidth: true
text: "ask"
current: side.approval === 0
onClicked: side.approvalRequested(0)
}
Seg {
Layout.fillWidth: true
text: "auto"
current: side.approval === 1
onClicked: side.approvalRequested(1)
}
Seg {
Layout.fillWidth: true
text: "yolo"
current: side.approval === 2
onClicked: side.approvalRequested(2)
}
}
Text {
Layout.fillWidth: true
text: side.approval === 0 ? "every tool asks first"
: side.approval === 2 ? "never asks — writes go through"
: "sandbox and rules decide, asks on doubt"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 2
spacing: Theme.gap
Switch {
id: planSwitch
objectName: "planSwitch"
checked: side.planMode
implicitHeight: Theme.touchTarget
onClicked: side.planModeRequested(checked)
}
Text {
text: "Plan mode"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: side.planMode
text: "read-only tools and a planning note"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- the plan the engine keeps ---
TodoPanel {
Layout.fillWidth: true
text: side.todo
}
Text {
Layout.fillWidth: true
Layout.topMargin: Theme.gap
text: "engine in-process · v" + Qt.application.version
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
// One place for the look: colors, spacing, fonts. The window, the cards and
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
// Kirigami.Theme) is a single file.
pragma Singleton
import QtQuick
QtObject {
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
readonly property color bg: "#15181a"
readonly property color surface: "#1e2225"
readonly property color surfaceAlt: "#262b2f"
readonly property color surfaceHi: "#313840"
readonly property color border: "#343b41"
readonly property color text: "#eff0f1"
readonly property color textDim: "#9aa2a8"
readonly property color textFaint: "#6d757b"
readonly property color accent: "#3daee9"
readonly property color accentDim: "#1d6f95"
readonly property color ok: "#27ae60"
readonly property color warn: "#f67400"
readonly property color err: "#da4453"
readonly property int radius: 6
readonly property int radiusSm: 3
readonly property int gap: 8
readonly property int gapL: 14
readonly property int pad: 12
readonly property int padL: 16
readonly property int bodySize: 14
readonly property int smallSize: 12
readonly property int monoSize: 13
readonly property string mono: "monospace"
readonly property int sidebarWidth: 268
readonly property int contentMax: 1100
// Layout: below the breakpoint the sidebar slides over the transcript
// instead of taking a column, and controls stay thumb-sized above it
// (a finger needs ~34-40 px, a mouse is happy with 26).
readonly property int compactBreakpoint: 880
readonly property int touchTarget: 34
readonly property int segmentHeight: 30
readonly property int headerHeight: 42
readonly property int jumpButton: 40
// The tool output / diff line colors, used by ToolCard.
function lineColor(line) {
if (line.startsWith("@@"))
return accent
if (line.startsWith("+++") || line.startsWith("---"))
return textDim
if (line.startsWith("+"))
return ok
if (line.startsWith("-"))
return err
return text
}
}
+71
View File
@@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The model's reasoning stream, collapsed by default: it is context, not the
// answer.
Rectangle {
id: block
property string text: ""
property bool complete: false
property bool expanded: false
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
opacity: 0.9
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
leftMargin: Theme.pad
rightMargin: Theme.pad
topMargin: Theme.pad / 2
}
spacing: 2
RowLayout {
spacing: Theme.gap
Text {
text: block.expanded ? "▾" : "▸"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
text: block.complete ? "thinking" : "thinking…"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
visible: !block.expanded
text: block.text.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: block.expanded = !block.expanded
}
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
visible: block.expanded
text: block.text
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.italic: true
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's todo list (on_todo), rendered as a small checklist. The text
// is markdown-ish: "- [x] thing" lines.
ColumnLayout {
id: panel
property string text: ""
function entries() {
return text.split("\n")
.map(function (l) { return l.trim() })
.filter(function (l) { return l.length > 0 })
.map(function (l) {
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
})
}
spacing: 6
Text {
text: "Plan"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Repeater {
model: panel.entries()
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
Layout.alignment: Qt.AlignTop
text: modelData.done ? "✓" : "○"
color: modelData.done ? Theme.ok : Theme.textFaint
font.pixelSize: Theme.smallSize
}
Text {
Layout.fillWidth: true
text: modelData.label
color: modelData.done ? Theme.textFaint : Theme.textDim
font.pixelSize: Theme.smallSize
font.strikeout: modelData.done
wrapMode: Text.Wrap
}
}
}
Text {
Layout.fillWidth: true
visible: panel.entries().length === 0
text: "no plan yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
+155
View File
@@ -0,0 +1,155 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A tool call: what ran, whether it is still running, and what came back.
// Long output stays folded until asked for, diff lines are colored.
Rectangle {
id: card
property string name: ""
property string state: "pending" // pending | ready | running | done | denied
property string detail: ""
property string output: ""
property int exitCode: 0
property bool hasResult: false
property bool expanded: false
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: running ? Theme.warn
: hasResult && exitCode !== 0 ? Theme.err
: Theme.ok
readonly property string stateText: state === "pending" ? "reading"
: state === "ready" ? "queued"
: state === "running" ? "running"
: state === "denied" ? "denied"
: hasResult && exitCode !== 0 ? "failed" : "done"
function lines() {
const all = output.split("\n")
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
}
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: expanded ? Theme.stateColor : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
spacing: Theme.gap
BusyIndicator {
visible: card.running
running: card.running
implicitWidth: 16
implicitHeight: 16
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
visible: !card.running
Layout.alignment: Qt.AlignVCenter
width: 6
height: 6
radius: 3
color: card.stateColor
}
Text {
text: card.name
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
font.bold: true
}
Rectangle {
Layout.alignment: Qt.AlignVCenter
radius: Theme.radiusSm
color: "transparent"
border.width: 1
border.color: card.stateColor
implicitWidth: chip.implicitWidth + 10
implicitHeight: chip.implicitHeight + 2
Text {
id: chip
anchors.centerIn: parent
text: card.stateText + (card.hasResult ? " · exit " + card.exitCode : "")
color: card.stateColor
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: card.detail.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.output.length > 0
contentItem: Text {
text: card.expanded ? "▾" : "▸"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: card.expanded = !card.expanded
}
}
// Folded view: the first line of the result, so a done card still says
// something without being opened.
Text {
Layout.fillWidth: true
visible: !card.expanded && card.output.length > 0
text: {
const l = card.output.split("\n")
return l[0] + (l.length > 1 ? " …" : "")
}
elide: Text.ElideRight
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ColumnLayout {
Layout.fillWidth: true
visible: card.expanded && card.output.length > 0
spacing: 0
Repeater {
model: card.expanded ? card.lines() : []
Text {
Layout.fillWidth: true
text: modelData.length ? modelData : " "
color: Theme.lineColor(modelData)
font.family: Theme.mono
font.pixelSize: Theme.monoSize
wrapMode: Text.NoWrap
textFormat: Text.PlainText
}
}
Text {
Layout.topMargin: 4
visible: card.output.split("\n").length > 500
text: "output truncated for the view"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}
+25 -1
View File
@@ -5,6 +5,10 @@
#include <QFileInfo> #include <QFileInfo>
#include <QThread> #include <QThread>
#include <cstdlib>
#include "cst_web_md.h"
/* --- the worker thread --------------------------------------------------- */ /* --- the worker thread --------------------------------------------------- */
/* One turn: the whole cst_agent_run_turn_ex() call, on its own thread. The /* 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. */ /* The session file is created by the engine; attachments live next to it. */
emit workspaceChanged(); emit workspaceChanged();
emit configChanged();
return true; 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() { void AgentCore::close() {
if (!m_agent) if (!m_agent)
return; return;
@@ -215,12 +233,14 @@ void AgentCore::setApproval(int approval) {
m_cfg.approval = approval; m_cfg.approval = approval;
if (m_agent) if (m_agent)
cst_agent_config_mut(m_agent)->approval = approval; cst_agent_config_mut(m_agent)->approval = approval;
emit approvalChanged();
} }
void AgentCore::setPlanMode(bool on) { void AgentCore::setPlanMode(bool on) {
m_cfg.plan_mode = on ? 1 : 0; m_cfg.plan_mode = on ? 1 : 0;
if (m_agent) if (m_agent)
cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode; cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode;
emit planModeChanged();
} }
/* --- tools the front end provides ---------------------------------------- */ /* --- 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) { void AgentCore::hookTodo(const char *text, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud); AgentCore *self = static_cast<AgentCore *>(ud);
const QString t = QString::fromUtf8(text ? text : ""); 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(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged) Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged) 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: public:
explicit AgentCore(QObject *parent = nullptr); explicit AgentCore(QObject *parent = nullptr);
@@ -38,6 +43,15 @@ class AgentCore : public QObject {
bool running() const { return m_running; } bool running() const { return m_running; }
QString workspace() const { return m_workspace; } QString workspace() const { return m_workspace; }
QString mode() const { return QString::fromUtf8(m_cfg.mode); } 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). */ /* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; } cst_agent_t *agent() const { return m_agent; }
@@ -68,6 +82,9 @@ class AgentCore : public QObject {
void setAutoApprove(bool on) { m_autoApprove = on; } void setAutoApprove(bool on) { m_autoApprove = on; }
signals: signals:
void configChanged();
void approvalChanged();
void planModeChanged();
void statusChanged(const QString &text); void statusChanged(const QString &text);
void assistantDelta(const QString &text); void assistantDelta(const QString &text);
void thinkDelta(const QString &text); void thinkDelta(const QString &text);
@@ -121,6 +138,7 @@ class AgentCore : public QObject {
int m_permAnswer = CST_PERM_DENY; int m_permAnswer = CST_PERM_DENY;
bool m_permPending = false; bool m_permPending = false;
QString m_question; QString m_question;
QString m_todo;
QByteArray m_answer; QByteArray m_answer;
bool m_questionPending = false; bool m_questionPending = false;
bool m_autoApprove = false; bool m_autoApprove = false;
+40 -1
View File
@@ -1,6 +1,7 @@
/* cst-agent-app: the Qt front end. /* cst-agent-app: the Qt front end.
* *
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k] * 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 * cst-agent-app --selftest embedding checks, no window, no network
* *
* The engine runs in this process (see app/README.md); nothing listens on a * The engine runs in this process (see app/README.md); nothing listens on a
@@ -13,6 +14,7 @@
#include <QGuiApplication> #include <QGuiApplication>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickStyle>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QTextStream> #include <QTextStream>
@@ -23,6 +25,7 @@
#include "core/AgentCore.h" #include "core/AgentCore.h"
#include "core/ToolProvider.h" #include "core/ToolProvider.h"
#include "ui/UiTest.h"
#include "cst_version.h" #include "cst_version.h"
namespace { namespace {
@@ -97,6 +100,11 @@ int main(int argc, char **argv) {
} }
QGuiApplication app(argc, 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::setApplicationName(QStringLiteral("cst-agent-app"));
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION)); QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent")); QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
@@ -116,10 +124,18 @@ int main(int argc, char **argv) {
QStringLiteral("model name"), QStringLiteral("name")); QStringLiteral("model name"), QStringLiteral("name"));
QCommandLineOption planOpt(QStringLiteral("plan"), QCommandLineOption planOpt(QStringLiteral("plan"),
QStringLiteral("start in plan mode")); 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(wsOpt);
parser.addOption(providerOpt); parser.addOption(providerOpt);
parser.addOption(modelOpt); parser.addOption(modelOpt);
parser.addOption(planOpt); parser.addOption(planOpt);
parser.addOption(uitestOpt);
parser.addOption(sizeOpt);
parser.process(app); parser.process(app);
QString ws = parser.value(wsOpt); QString ws = parser.value(wsOpt);
@@ -138,9 +154,32 @@ int main(int argc, char **argv) {
if (parser.isSet(planOpt)) if (parser.isSet(planOpt))
core.setPlanMode(true); 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; QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core); 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( QObject::connect(
&engine, &QQmlApplicationEngine::objectCreated, &app, &engine, &QQmlApplicationEngine::objectCreated, &app,
[url](QObject *obj, const QUrl &objUrl) { [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 */
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #define CST_VERSION_MINOR 8
#define CST_VERSION_PATCH 7 #define CST_VERSION_PATCH 8
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)