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
+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
}
}
}
}