Files
CST-agent/app/qml/Main.qml
T
WHC da812cc7e8 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.
2026-10-11 11:58:23 +08:00

406 lines
14 KiB
QML

// The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
//
// 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
import QtQuick.Layouts
ApplicationWindow {
id: win
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
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: 22
implicitHeight: 22
}
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 = ""
}
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
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
spacing: 0
ChatView {
id: chat
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
running: agent.running
}
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) }
}
}
}
// ---- 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) { win.appendDelta("message", "agent", text) }
function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, name, 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) {
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(question, options) {
ask.question = question
ask.optionText = options
ask.open()
}
function onTurnFinished(rc, err) {
win.closeOpenRows("")
if (err.length)
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("")
}
}
}