Files
CST-agent/archive/qt-app/qml/ToolCard.qml
T
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

226 lines
8.1 KiB
QML

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
// | unfinished (replayed, no result)
property string detail: ""
property string output: ""
property int exitCode: 0
property bool hasResult: false
property bool expanded: false
property string images: ""
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: state === "unfinished" ? Theme.textFaint
: 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"
: state === "unfinished" ? "no result"
: hasResult && exitCode !== 0 ? "failed" : "done"
/* How long this call has been going. A tool that takes minutes -- a
* build, a wide search, a command nobody can predict -- must not read as
* a window that has hung: while the card runs, its chip counts. */
property double startedAt: 0
property int runningFor: 0
function elapsedText() {
const s = runningFor
if (s < 60)
return s + "s"
const m = Math.floor(s / 60)
if (m < 60)
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
}
/* The clock starts when the card starts running -- and a card that is
* already running when it appears (a replayed transcript) starts one too,
* hence Component.onCompleted. */
function noteRunning() {
if (running && startedAt === 0)
startedAt = Date.now()
else if (!running)
startedAt = 0
}
onRunningChanged: noteRunning()
Component.onCompleted: noteRunning()
Timer {
interval: 1000
repeat: true
running: card.running && card.startedAt > 0
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
}
/* What the chip says: the state, how long it has been running (from five
* seconds on -- before that it is noise), or the exit code once there is
* a result. "no result" is not an outcome, so no exit code belongs on
* it. */
function chipText() {
if (running)
return runningFor >= 5
? stateText + " · " + elapsedText()
: stateText
return stateText + (hasResult && state !== "unfinished"
? " · exit " + exitCode : "")
}
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
}
/* What kind of tool this was, from the icon theme (Theme.toolIcon
* maps the engine's names onto Breeze ones). */
ThemeIcon {
Layout.alignment: Qt.AlignVCenter
name: Theme.toolIcon(card.name)
size: 16
color: Theme.textDim
}
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
objectName: "toolChip"
anchors.centerIn: parent
text: card.chipText()
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: ThemeIcon {
name: card.expanded ? "arrow-down" : "arrow-right"
glyph: card.expanded ? "▾" : "▸"
size: 14
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: card.expanded = !card.expanded
}
}
// What the tool made (view_image, an MCP image): shown as it is, with
// the output left folded -- the picture is the result.
ImageStrip {
Layout.fillWidth: true
visible: card.images.length > 0
text: card.images
}
// 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
}
}
}
}