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>
270 lines
10 KiB
QML
270 lines
10 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
|
||
// The files a message or a tool call carries.
|
||
//
|
||
// A picture is a thumbnail from the session's own store (image://cstattach/,
|
||
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
|
||
// images when it is tapped. A file the agent delivered (deliver_file) is a card
|
||
// with the name, the type, the size and two buttons: open and share. The names
|
||
// are the ones the engine wrote next to the session -- a card never invents a
|
||
// path.
|
||
//
|
||
// One line per file, written by AgentCore.cpp (filesText):
|
||
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
|
||
// <w>x<h>" for pictures; both shapes are read here.
|
||
Flow {
|
||
id: strip
|
||
objectName: "imageStrip"
|
||
/* A row carries this as text: a ListModel role cannot hold a list. */
|
||
property string text: ""
|
||
property var files: decode(text)
|
||
/* The last refusal, so a tap that cannot work says why (the header's own
|
||
* status line is the engine's). */
|
||
property string errorText: ""
|
||
spacing: 6
|
||
|
||
function decode(t) {
|
||
const out = []
|
||
const lines = String(t).split("\n")
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (!lines[i].length)
|
||
continue
|
||
const f = lines[i].split("|")
|
||
if (!f[0].length)
|
||
continue
|
||
const old = f.length < 7 // a picture line from an old session
|
||
const dim = String(old ? f[3] : f[6]).split("x")
|
||
let w = 0, h = 0
|
||
if (dim.length === 2) {
|
||
w = parseInt(dim[0], 10) || 0
|
||
h = parseInt(dim[1], 10) || 0
|
||
}
|
||
const rel = old ? f[0] : f[1]
|
||
out.push({ kind: old ? "img" : f[0],
|
||
rel: rel,
|
||
name: (old ? f[1] : f[2]) || rel,
|
||
origin: (old ? f[2] : f[3]) || "",
|
||
mime: old ? "image/*" : (f[4] || ""),
|
||
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
|
||
w: w, h: h,
|
||
url: "image://cstattach/" + rel })
|
||
}
|
||
return out
|
||
}
|
||
|
||
function humanSize(n) {
|
||
if (!n || n <= 0)
|
||
return ""
|
||
if (n >= 1024 * 1024)
|
||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||
if (n >= 1024)
|
||
return Math.round(n / 1024) + " KB"
|
||
return n + " B"
|
||
}
|
||
|
||
/* The two things a file card does. The engine answers with "" when it
|
||
* worked (it puts the line in the header itself) or a reason. */
|
||
function openFile(rel) {
|
||
errorText = agent.openArtifact(rel)
|
||
}
|
||
function shareFile(rel) {
|
||
errorText = agent.shareArtifact(rel)
|
||
}
|
||
|
||
Repeater {
|
||
model: strip.files
|
||
|
||
delegate: Rectangle {
|
||
id: cell
|
||
required property var modelData
|
||
required property int index
|
||
readonly property bool isFile: String(modelData.kind) !== "img"
|
||
readonly property bool hovered: mouse.containsMouse
|
||
/* Images keep their own shape, capped; a file card is a fixed
|
||
* width so a long name cannot stretch the transcript. */
|
||
readonly property real ratio: {
|
||
const w = Number(modelData.w), h = Number(modelData.h)
|
||
return (w > 0 && h > 0) ? w / h : 1.5
|
||
}
|
||
readonly property int cap: 150
|
||
|
||
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
|
||
width: isFile ? 300
|
||
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
|
||
height: isFile ? 52
|
||
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
|
||
radius: Theme.radiusSm
|
||
color: Theme.bg
|
||
border.width: 1
|
||
border.color: hovered ? Theme.accentDim : Theme.border
|
||
clip: true
|
||
|
||
MouseArea {
|
||
id: mouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
acceptedButtons: Qt.NoButton
|
||
}
|
||
|
||
// --- a picture: tap it to look at it ---------------------------
|
||
Image {
|
||
id: pic
|
||
anchors.fill: parent
|
||
anchors.margins: 2
|
||
visible: !cell.isFile && status === Image.Ready
|
||
source: String(cell.modelData.url)
|
||
sourceSize.width: 512
|
||
sourceSize.height: 512
|
||
fillMode: Image.PreserveAspectFit
|
||
asynchronous: false
|
||
}
|
||
|
||
Text {
|
||
anchors.centerIn: parent
|
||
width: parent.width - 8
|
||
visible: !cell.isFile && pic.status === Image.Error
|
||
text: String(cell.modelData.name)
|
||
elide: Text.ElideMiddle
|
||
horizontalAlignment: Text.AlignHCenter
|
||
color: Theme.textFaint
|
||
font.pixelSize: Theme.smallSize
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
enabled: !cell.isFile
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||
}
|
||
|
||
// --- a delivered file: name, type, open, share -----------------
|
||
RowLayout {
|
||
id: row
|
||
visible: cell.isFile
|
||
anchors.fill: parent
|
||
anchors.leftMargin: 8
|
||
anchors.rightMargin: 6
|
||
spacing: 6
|
||
|
||
/* The extension stands in for an icon: no theme lookup, and a
|
||
* ZIP that says ZIP is more use than a generic sheet. */
|
||
Rectangle {
|
||
implicitWidth: 40
|
||
implicitHeight: 30
|
||
radius: Theme.radiusSm
|
||
color: Theme.surfaceHi
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: {
|
||
const n = String(cell.modelData.name)
|
||
const dot = n.lastIndexOf(".")
|
||
if (dot < 0 || dot === n.length - 1)
|
||
return "FILE"
|
||
return n.slice(dot + 1, dot + 5).toUpperCase()
|
||
}
|
||
color: Theme.textDim
|
||
font.pixelSize: Theme.smallSize
|
||
font.bold: true
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 0
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: String(cell.modelData.name)
|
||
color: Theme.text
|
||
font.pixelSize: Theme.bodySize
|
||
elide: Text.ElideMiddle
|
||
}
|
||
Text {
|
||
Layout.fillWidth: true
|
||
text: {
|
||
const m = cell.modelData
|
||
let s = m.mime ? String(m.mime) : ""
|
||
const size = strip.humanSize(m.bytes)
|
||
if (size)
|
||
s += (s ? " · " : "") + size
|
||
if (m.origin && m.origin !== "user" && m.origin !== "tool")
|
||
s += (s ? " · " : "") + m.origin
|
||
return s
|
||
}
|
||
color: Theme.textFaint
|
||
font.pixelSize: Theme.smallSize
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
ToolButton {
|
||
objectName: "fileOpen" + cell.index
|
||
implicitWidth: Theme.touchTarget
|
||
implicitHeight: Theme.touchTarget
|
||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||
contentItem: Text {
|
||
text: "Open"
|
||
color: parent.hovered ? Theme.text : Theme.textDim
|
||
font.pixelSize: Theme.smallSize
|
||
horizontalAlignment: Text.AlignHCenter
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
background: Rectangle {
|
||
radius: Theme.radiusSm
|
||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
}
|
||
}
|
||
|
||
ToolButton {
|
||
objectName: "fileShare" + cell.index
|
||
implicitWidth: Theme.touchTarget
|
||
implicitHeight: Theme.touchTarget
|
||
onClicked: strip.shareFile(String(cell.modelData.rel))
|
||
contentItem: Text {
|
||
text: "Share"
|
||
color: parent.hovered ? Theme.text : Theme.textDim
|
||
font.pixelSize: Theme.smallSize
|
||
horizontalAlignment: Text.AlignHCenter
|
||
verticalAlignment: Text.AlignVCenter
|
||
}
|
||
background: Rectangle {
|
||
radius: Theme.radiusSm
|
||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
}
|
||
}
|
||
}
|
||
|
||
ToolTip.visible: hovered
|
||
ToolTip.delay: 400
|
||
ToolTip.text: {
|
||
const m = cell.modelData
|
||
let s = String(m.name)
|
||
if (m.w > 0 && m.h > 0)
|
||
s += " · " + m.w + "×" + m.h
|
||
if (m.mime && m.kind !== "img")
|
||
s += " · " + m.mime
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
|
||
/* A tap that could not work says why, right where the card is: a phone
|
||
* with nothing installed for the type is the common case ("no app on the
|
||
* phone opens report.md"), and a silence there looks like a dead button. */
|
||
Text {
|
||
objectName: "fileError"
|
||
visible: strip.errorText.length > 0
|
||
width: strip.width
|
||
wrapMode: Text.WordWrap
|
||
text: strip.errorText
|
||
color: Theme.err
|
||
font.pixelSize: Theme.smallSize
|
||
}
|
||
}
|