diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index b023752..ae962bf 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -64,7 +64,7 @@ endif() # --- the 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) message(WARNING "app: Qt 6 Quick/Qml not found, skipping cst-agent-app " @@ -80,6 +80,7 @@ if(CST_APP_GUI) src/main.cpp src/core/AgentCore.cpp src/core/ToolProvider.cpp + src/ui/UiTest.cpp src/platform/linux/NotifyTool.cpp) target_include_directories(cst-agent-app PRIVATE src "${CMAKE_CURRENT_SOURCE_DIR}/../include") @@ -89,14 +90,37 @@ if(CST_APP_GUI) CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}") endif() 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) set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) 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 URI CstAgent 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() diff --git a/app/README.md b/app/README.md index 735c693..eb31b92 100644 --- a/app/README.md +++ b/app/README.md @@ -24,6 +24,8 @@ QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest # the app alone ./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 ``` @@ -61,6 +63,35 @@ source. - `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal 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 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] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule - [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test -- [ ] QML window against a real provider (needs Qt Quick) -- [ ] session list / switch, attachments, diff viewer, settings page +- [x] QML shell: transcript cards, tool cards with diff coloring, thinking + 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 - [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/AskDialog.qml b/app/qml/AskDialog.qml new file mode 100644 index 0000000..c8d075e --- /dev/null +++ b/app/qml/AskDialog.qml @@ -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() +} diff --git a/app/qml/ChatView.qml b/app/qml/ChatView.qml new file mode 100644 index 0000000..183b8f5 --- /dev/null +++ b/app/qml/ChatView.qml @@ -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() + } + } +} diff --git a/app/qml/Composer.qml b/app/qml/Composer.qml new file mode 100644 index 0000000..74a5ae1 --- /dev/null +++ b/app/qml/Composer.qml @@ -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 + } + } +} diff --git a/app/qml/ConsentDialog.qml b/app/qml/ConsentDialog.qml new file mode 100644 index 0000000..c1ef148 --- /dev/null +++ b/app/qml/ConsentDialog.qml @@ -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() } + } + } +} diff --git a/app/qml/Main.qml b/app/qml/Main.qml index dff61e5..1394564 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -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, -// cards, drawer for navigation) so that swapping `import QtQuick.Controls` -// for `import org.kde.kirigami as Kirigami` and Page/Card wrappers is a QML -// change, not a rewrite. +// One shell, three sizes: the sidebar is docked on a desktop (and can be +// dragged), slides over the transcript when the window is narrow, and every +// control keeps a finger-sized hit area for touch screens. Qt Quick Controls +// 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.Controls @@ -11,219 +13,393 @@ import QtQuick.Layouts ApplicationWindow { id: win - width: 980 - height: 720 + width: initialWidth > 0 ? initialWidth : 1180 + height: initialHeight > 0 ? initialHeight : 800 + minimumWidth: 360 + minimumHeight: 420 visible: true + color: Theme.bg 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 } + 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 { + id: appBar + implicitHeight: Theme.headerHeight + background: Rectangle { + color: Theme.surface + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: Theme.border + } + } RowLayout { anchors.fill: parent - Label { - text: agent.workspace.length ? agent.workspace : "(no workspace)" - elide: Text.ElideMiddle + anchors.leftMargin: Theme.pad + anchors.rightMargin: Theme.gap + 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 + 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 { running: agent.running visible: agent.running - implicitWidth: 24 - implicitHeight: 24 + implicitWidth: 22 + implicitHeight: 22 } - ComboBox { - id: modeBox - model: ["coding", "general"] - currentIndex: agent.mode === "general" ? 1 : 0 - onActivated: agent.setMode(model[currentIndex]) - } - } - } - - // ---- consent: the engine's on_permission hook waits for this ---- - Dialog { - id: consent - anchors.centerIn: parent - width: Math.min(720, win.width - 40) - modal: true - property string tool: "" - property string detail: "" - property string preview: "" - title: "Allow " + tool + "?" - contentItem: ColumnLayout { - Label { text: consent.detail; wrapMode: Text.Wrap; Layout.fillWidth: true } - 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 + ToolButton { + objectName: "clearButton" + enabled: transcript.count > 0 && !agent.running + implicitWidth: win.compact ? Theme.touchTarget : 56 + implicitHeight: Theme.touchTarget + ToolTip.visible: hovered + ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history" + contentItem: Text { + text: "Clear" + color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint + font.pixelSize: Theme.smallSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent" + } + onClicked: { + transcript.clear() + win.statusText = "" } } } - 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 ---- - 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 { + RowLayout { anchors.fill: parent - anchors.margins: 12 - spacing: 8 + spacing: 0 - ListView { - id: chat + ShellSidebar { + 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.fillHeight: true - model: transcript - 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() - } + spacing: 0 - RowLayout { - Layout.fillWidth: true - TextArea { - id: composer + ChatView { + id: chat Layout.fillWidth: true - Layout.preferredHeight: 90 - placeholderText: agent.running ? "… working (Esc cancels)" - : "Ask something (Enter sends, Shift+Enter for a new line)" - 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 - } - } + Layout.fillHeight: true + model: transcript + running: agent.running } - ColumnLayout { - Button { - id: sendButton - text: "Send" - enabled: !agent.running && composer.text.trim().length > 0 - onClicked: { - transcript.append({ role: "user", text: composer.text }) - transcript.append({ role: "agent", text: "" }) - agent.send(composer.text) - composer.text = "" - } - } - Button { - text: "Stop" - enabled: agent.running - onClicked: agent.cancel() - } + + Composer { + Layout.fillWidth: true + Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL + Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL + Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL + running: agent.running + compact: win.compact + onSubmitted: function (text) { win.submit(text) } } } } - Shortcut { sequence: "Escape"; onActivated: agent.cancel() } - - // ---- engine -> UI ---- - function appendToAgent(text) { - if (transcript.count === 0) - transcript.append({ role: "agent", text: "" }) - const last = transcript.count - 1 - transcript.setProperty(last, "text", transcript.get(last).text + text) + // ---- compact: the sidebar slides in over the transcript -------------- + Rectangle { + id: scrim + objectName: "navScrim" + visible: win.navOpen && win.compact + anchors.fill: parent + color: "#000000" + 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 { target: agent - function onAssistantDelta(text) { appendToAgent(text) } - function onStatusChanged(text) { - transcript.append({ role: "agent", text: "· " + text }) - } + + function onAssistantDelta(text) { win.appendDelta("message", "agent", text) } + function onThinkDelta(text) { win.appendDelta("think", "agent", text) } + + function onStatusChanged(text) { win.statusText = text } + function onToolEvent(phase, name, detail) { - transcript.append({ - role: "agent", - text: "[" + name + " " + phase + (detail.length ? ": " + detail : "") + "]" - }) + win.closeOpenRows("tool") + let i = win.findTool(name) + 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) { - transcript.append({ - role: "agent", - text: "[" + name + " exit=" + exitCode + "]\n" + output - }) + let i = win.findTool(name) + if (i < 0) + 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) { consent.tool = tool consent.detail = detail consent.preview = preview consent.open() } - function onQuestionAsked(text, options) { - question.text = text - question.options = options - answerField.text = "" - question.open() + + function onQuestionAsked(question, options) { + ask.question = question + ask.optionText = options + ask.open() } + function onTurnFinished(rc, err) { + win.closeOpenRows("") 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("") } } } diff --git a/app/qml/MessageCard.qml b/app/qml/MessageCard.qml new file mode 100644 index 0000000..c64efcb --- /dev/null +++ b/app/qml/MessageCard.qml @@ -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) } + } + } +} diff --git a/app/qml/NoteLine.qml b/app/qml/NoteLine.qml new file mode 100644 index 0000000..9440e60 --- /dev/null +++ b/app/qml/NoteLine.qml @@ -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 + } +} diff --git a/app/qml/Sidebar.qml b/app/qml/Sidebar.qml new file mode 100644 index 0000000..b41eba1 --- /dev/null +++ b/app/qml/Sidebar.qml @@ -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 + } + } + } +} diff --git a/app/qml/Theme.qml b/app/qml/Theme.qml new file mode 100644 index 0000000..0d1bd36 --- /dev/null +++ b/app/qml/Theme.qml @@ -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 + } +} diff --git a/app/qml/ThinkBlock.qml b/app/qml/ThinkBlock.qml new file mode 100644 index 0000000..e59253e --- /dev/null +++ b/app/qml/ThinkBlock.qml @@ -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 + } + } +} diff --git a/app/qml/TodoPanel.qml b/app/qml/TodoPanel.qml new file mode 100644 index 0000000..74caf16 --- /dev/null +++ b/app/qml/TodoPanel.qml @@ -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 + } +} diff --git a/app/qml/ToolCard.qml b/app/qml/ToolCard.qml new file mode 100644 index 0000000..e05429b --- /dev/null +++ b/app/qml/ToolCard.qml @@ -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 + } + } + } +} diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 97f813b..80672fb 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -5,6 +5,10 @@ #include #include +#include + +#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(in.size())); + if (!html) + return text.toHtmlEscaped().replace(QLatin1Char('\n'), + QStringLiteral("
")); + 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(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); + }); } diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index c8cadb4..d5ac545 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -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; diff --git a/app/src/main.cpp b/app/src/main.cpp index d494891..14638d0 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -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 #include #include +#include #include #include #include @@ -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) { diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp new file mode 100644 index 0000000..4b9079f --- /dev/null +++ b/app/src/ui/UiTest.cpp @@ -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 +#include +#include +#include +#include +#include + +#include + +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(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(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 diff --git a/app/src/ui/UiTest.h b/app/src/ui/UiTest.h new file mode 100644 index 0000000..26c4c83 --- /dev/null +++ b/app/src/ui/UiTest.h @@ -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 + +#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 */ diff --git a/include/cst_version.h b/include/cst_version.h index 8cee0a0..ce98b36 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #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) CST_VERSION_STR_(x)