Files
WHCandCursor 5538ed3412
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s
refactor: archive GUI clients; keep minimal TUI with hooks/API
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 22:49:23 +08:00

718 lines
26 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 var sessionsView: [] // the store, for the sidebar
property bool navOpen: false // the sliding sidebar, compact only
TaskPanel { id: taskPanel; anchors.centerIn: parent }
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
sessions: win.sessionsView
sessionId: agent.sessionId
onModeRequested: function (m) { agent.setMode(m) }
onSessionRequested: function (id) { win.switchSession(id) }
onSessionNewRequested: win.newSession()
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
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: "", html: "", complete: false,
name: "", callId: "", state: state, detail: "", output: "",
exitCode: 0, images: "" })
}
// 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 open card of this call, -1 when none. The id is what tells two calls
// of the same tool apart (pairing by name left the first card running
// forever); a call without one -- an old text-protocol session -- falls
// back to the name.
function findTool(callId, name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool" || r.complete)
continue
if (callId.length) {
if (r.callId === callId)
return i
continue /* another call: its card is further back */
}
if (r.callId.length)
continue
if (r.name === name)
return i
return -1
}
return -1
}
function openTool(callId, name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
transcript.setProperty(lastIndex(), "callId", callId)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
name: "", callId: "", state: "", detail: "", output: "",
exitCode: 0, images: "" })
agent.send(text)
}
// A turn is over: nothing else is coming, so nothing may keep spinning.
function closeAllRows() {
for (let i = 0; i < transcript.count; i++) {
const r = transcript.get(i)
if (r.complete)
continue
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
r.state === "running"))
transcript.setProperty(i, "state", "unfinished")
transcript.setProperty(i, "complete", true)
}
}
// Markdown while it streams: the open answer is re-rendered with the
// engine's own renderer (the timer below), so the text fills in formatted
// instead of turning into markdown when the turn ends.
function renderStreaming() {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "message" || r.role !== "agent" || r.complete)
continue
const html = agent.renderMarkdown(r.text)
if (html !== r.html)
transcript.setProperty(i, "html", html)
return
}
}
// ---- sessions --------------------------------------------------------
function reloadSessions() { win.sessionsView = agent.sessions() }
// A stored session comes back as rows: the engine holds the transcript
// (AgentCore::history()), these rows only mirror it.
function replay() {
transcript.clear()
const rows = agent.history()
for (let i = 0; i < rows.length; i++)
transcript.append(rows[i])
chat.stickBottom = true
}
function switchSession(id) {
if (agent.switchSession(id))
win.reloadSessions()
}
function newSession() {
if (agent.newSession()) {
transcript.clear()
win.reloadSessions()
}
}
function deleteSession(id) {
if (agent.deleteSession(id))
win.reloadSessions()
}
// ---- provider, model, key --------------------------------------------
function openSettings() {
settings.load(agent.providers(), agent.provider, agent.model)
settings.open()
}
function applySettings(provider, model, key, temperature, topP, maxTokens) {
/* The parameters are stored first: useProvider() then picks up what
* was remembered for whichever provider it ends on. */
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
const err = perr.length ? perr : agent.useProvider(provider, model, key)
settings.note = err.length ? err
: "using " + agent.provider + " / " + agent.model
settings.choices = agent.modelChoices(provider)
settings.syncPick()
}
/* The engine opens the workspace's last session (cst_agent_open()), so the
* window is created with a transcript already in it: draw it, or the pane
* starts empty while the sidebar points at the very session it belongs to.
* A switch draws it the same way (the sessionChanged handler below). */
Component.onCompleted: {
win.reloadSessions()
win.replay()
}
// ---- the shell -------------------------------------------------------
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
* to a style-owned contentItem, and on this phone that item sat 35 px below
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
* was drawn, and clicked, outside the header, over the sidebar. A
* Rectangle puts its children where it is (checked with --uitest on the
* device and on both styles at home). */
header: Rectangle {
id: appBar
objectName: "appBar"
implicitHeight: Theme.headerHeight
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: ThemeIcon {
name: "application-menu"
glyph: "☰"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
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: "settingsButton"
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Provider, model and key"
contentItem: ThemeIcon {
name: "settings-configure"
glyph: "⚙"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.openSettings()
}
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 = ""
}
}
ToolButton {
objectName: "taskPanelButton"
text: "Tasks"
implicitHeight: Theme.touchTarget
onClicked: taskPanel.open()
}
}
}
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
objectName: "chatView"
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
running: agent.running
}
Composer {
id: 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
attachments: agent.pending
onSubmitted: function (text) { win.submit(text) }
}
}
}
// ---- compact: the sidebar slides in over the transcript --------------
/* Files dropped anywhere on the window: the engine copies them next to
* the session and the next prompt carries them. The picker and Ctrl+V in
* the composer land in the same list. */
DropArea {
id: dropZone
objectName: "dropZone"
anchors.fill: parent
onDropped: function (drop) {
if (!drop.hasUrls)
return
let note = ""
let n = 0
for (let i = 0; i < drop.urls.length; i++) {
const r = agent.attachFile(composer.localPath(drop.urls[i]))
if (r.length)
note = r
else
n++
}
composer.note = note.length ? note
: n + (n === 1 ? " file attached" : " files attached")
drop.accept()
}
}
// While something is dragged over the window, say what will happen.
Rectangle {
anchors.fill: parent
visible: dropZone.containsDrag
color: "transparent"
border.width: 2
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "drop to attach"
color: Theme.accent
font.pixelSize: Theme.bodySize
font.bold: true
}
}
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) }
}
SettingsDialog {
id: settings
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
win.applySettings(provider, model, key, temperature, topP, maxTokens)
}
}
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 = ""
}
}
/* The back key a phone has instead of a mouse: it closes what is open, one
* layer at a time -- the dialog on top, then the sliding sidebar. With
* nothing open the key goes on to the platform, and a phone leaves the
* window (Qt finishes the activity), which is right for the last layer.
*
* Which layer that is, and whether there is one, lives in one place: the
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
* lets through. --uitest reads the same two functions. */
function backLayer() {
if (settings.visible)
return "settings"
if (ask.visible)
return "ask"
if (consent.visible)
return "consent"
if (win.navOpen)
return "drawer"
return ""
}
/* One layer back. The dialogs are closed on the next turn of the event
* loop: closing one from inside the key event leaves its modal overlay up
* in this Qt (the dialog says it is closed, the overlay still eats every
* click after it), which --uitest walks straight into. */
function stepBack() {
if (settings.visible)
Qt.callLater(settings.close)
else if (ask.visible)
Qt.callLater(ask.close)
else if (consent.visible)
Qt.callLater(consent.close)
else if (win.navOpen)
win.navOpen = false
}
// ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function
* here, so the same path can be driven from --uitest (which is how the
* two-calls-of-one-tool case stays fixed). */
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
function engineThink(text) { win.appendDelta("think", "agent", text) }
function engineToolEvent(phase, id, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(id, name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(id, 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(id, name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function engineToolResult(id, name, exitCode, output) {
let i = win.findTool(id, name)
if (i < 0) /* a result whose call is not in the view: its own card */
i = win.openTool(id, name, "done")
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "state", "done")
transcript.setProperty(i, "complete", true)
}
function engineTurnFinished(rc, err) {
win.renderStreaming(); /* one last render while the row is still open */
win.closeAllRows()
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, html: "",
complete: true, name: "", callId: "", state: "error",
detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
/* The images the prompt just carried (AgentCore::promptImages): the
* composer has appended the user row, so they attach to it. */
function enginePromptImages(images) {
if (!images.length)
return
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind === "message" && r.role === "user") {
transcript.setProperty(i, "images", images)
return
}
}
}
/* Files a tool produced, on the card of the call that made them. The
* engine reports the result first and the files after it, so the card is
* normally already complete by the time they arrive -- findTool() only
* looks at cards still running, which is why this looks for the call id
* itself (and falls back to findTool for a call that has no id). */
function engineToolImages(callId, images) {
if (!images.length)
return
let i = -1
if (callId.length) {
for (let k = transcript.count - 1; k >= 0; k--) {
const r = transcript.get(k)
if (r.kind === "tool" && r.callId === callId) {
i = k
break
}
}
} else {
i = win.findTool("", "")
}
if (i >= 0)
transcript.setProperty(i, "images", images)
}
/* For --uitest: one transcript row, or null. */
function rowAt(i) {
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
}
/* For --uitest: the same row as JSON (a QObject's return value does not
* survive QMetaObject::invokeMethod, a string does). */
function rowJson(i) {
return JSON.stringify(rowAt(i))
}
/* While a turn runs the open answer is re-rendered a few times a second:
* markdown text fills in formatted rather than at the end of the turn. */
Timer {
interval: 120
repeat: true
running: agent.running
onTriggered: win.renderStreaming()
}
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.engineAssistant(text) }
function onThinkDelta(text) { win.engineThink(text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, id, name, detail) {
win.engineToolEvent(phase, id, name, detail)
}
function onToolResult(id, name, exitCode, output) {
win.engineToolResult(id, name, exitCode, output)
}
function onToolImages(callId, images) {
win.engineToolImages(callId, images)
}
function onPromptImages(images) {
win.enginePromptImages(images)
}
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()
}
/* Nobody answered (the app was in the background, or the dialog sat
* there too long): the turn went on without it, so put the dialog
* away instead of leaving a question that is no longer being asked. */
function onPermissionExpired(tool) { consent.close() }
function onQuestionExpired() { ask.close() }
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() {
if (!agent.running)
win.closeAllRows()
}
}
}