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>
226 lines
8.1 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|