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>
253 lines
9.6 KiB
QML
253 lines
9.6 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Dialogs
|
|
import QtQuick.Layouts
|
|
|
|
// The input line: multiline, Enter sends, the button changes with the turn
|
|
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
|
// keyboard not to autocorrect what is usually code or a path.
|
|
//
|
|
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
|
|
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
|
|
// engine's pending list, which the next prompt carries.
|
|
Rectangle {
|
|
id: root
|
|
objectName: "composer"
|
|
property bool running: false
|
|
property bool compact: false
|
|
property string note: "" // a failure, or what was just attached
|
|
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
|
property alias text: input.text
|
|
signal submitted(string text)
|
|
|
|
function submit() {
|
|
const t = input.text.trim()
|
|
if (!t.length)
|
|
return
|
|
input.text = ""
|
|
submitted(t)
|
|
}
|
|
|
|
/* A file: from the picker, or a URL dropped on the window. */
|
|
function localPath(url) {
|
|
let s = String(url)
|
|
// A phone's picker (SAF) hands over content:// URIs: they are not
|
|
// paths and must not be decoded -- the app copies what they point at
|
|
// into its own directory before the engine sees it (see
|
|
// app::materializeAttachment).
|
|
if (s.startsWith("content://"))
|
|
return s
|
|
if (s.startsWith("file://"))
|
|
s = s.substring(7)
|
|
/* Windows keeps its drive letter after the slash. */
|
|
if (/^\/[A-Za-z]:/.test(s))
|
|
s = s.substring(1)
|
|
return decodeURIComponent(s)
|
|
}
|
|
|
|
function attachPath(path) {
|
|
const r = agent.attachFile(path)
|
|
note = r.length ? r : ""
|
|
}
|
|
|
|
radius: Theme.radius
|
|
color: Theme.surface
|
|
border.width: 1
|
|
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
|
/* One pad of frame on every side of the column: with the top margin
|
|
* alone the last row (the clip and Send) hung over the lower border,
|
|
* which is what put the clip on the frame instead of inside it. */
|
|
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
|
|
|
ColumnLayout {
|
|
id: layout
|
|
anchors {
|
|
fill: parent
|
|
margins: Theme.pad
|
|
}
|
|
spacing: 6
|
|
|
|
// What the next prompt will carry, in the engine's own words.
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
visible: root.attachments.length > 0
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: root.attachments
|
|
delegate: Rectangle {
|
|
id: chip
|
|
required property var modelData
|
|
required property int index
|
|
|
|
objectName: "attachChip" + chip.index
|
|
implicitWidth: chipText.implicitWidth + 14
|
|
implicitHeight: 24
|
|
radius: Theme.radiusSm
|
|
color: Theme.surfaceAlt
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
Text {
|
|
id: chipText
|
|
anchors.centerIn: parent
|
|
text: String(chip.modelData)
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
id: clearAtt
|
|
objectName: "clearAttachments"
|
|
text: "clear all"
|
|
implicitHeight: 24
|
|
onClicked: {
|
|
agent.clearPending()
|
|
root.note = ""
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
}
|
|
contentItem: Text {
|
|
text: clearAtt.text
|
|
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: root.note.length > 0
|
|
text: root.note
|
|
color: Theme.warn
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Theme.gap
|
|
|
|
TextArea {
|
|
id: input
|
|
objectName: "composerInput"
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
|
Math.max(56, contentHeight + 12))
|
|
placeholderText: root.running
|
|
? "Send a correction now — Esc pauses execution"
|
|
: (root.compact
|
|
? "Ask for something…"
|
|
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
|
placeholderTextColor: Theme.textFaint
|
|
color: Theme.text
|
|
font.pixelSize: Theme.bodySize
|
|
wrapMode: TextArea.Wrap
|
|
selectByMouse: true
|
|
selectionColor: Theme.accentDim
|
|
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
|
Qt.ImhNoAutoUppercase
|
|
background: Rectangle { color: "transparent" }
|
|
enabled: true
|
|
|
|
Keys.onPressed: function (ev) {
|
|
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
|
if (!(ev.modifiers & Qt.ShiftModifier)) {
|
|
root.submit()
|
|
ev.accepted = true
|
|
}
|
|
return
|
|
}
|
|
/* An image has nothing the editor could paste, so Ctrl+V
|
|
* becomes an attachment there; text stays with the
|
|
* editor's own paste. */
|
|
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
|
agent.clipboardHasImage()) {
|
|
root.note = agent.attachClipboard()
|
|
ev.accepted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolButton {
|
|
id: attach
|
|
objectName: "attachButton"
|
|
Layout.alignment: Qt.AlignBottom
|
|
implicitWidth: root.compact ? Theme.touchTarget : 40
|
|
implicitHeight: Theme.touchTarget + 4
|
|
enabled: !root.running
|
|
ToolTip.visible: hovered
|
|
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
|
contentItem: ThemeIcon {
|
|
name: "mail-attachment"
|
|
glyph: "📎"
|
|
size: 20
|
|
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
|
: Theme.textFaint
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
|
: "transparent"
|
|
}
|
|
onClicked: picker.open()
|
|
}
|
|
|
|
Button {
|
|
id: action
|
|
objectName: "sendButton"
|
|
Layout.alignment: Qt.AlignBottom
|
|
implicitWidth: root.compact ? 68 : 88
|
|
implicitHeight: Theme.touchTarget + 4
|
|
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
|
|
enabled: root.running || input.text.trim().length > 0
|
|
onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: !action.enabled ? Theme.surfaceAlt
|
|
: root.running ? (action.hovered ? "#e05666" : Theme.err)
|
|
: (action.hovered ? "#5cc0f0" : Theme.accent)
|
|
}
|
|
contentItem: Text {
|
|
text: action.text
|
|
color: action.enabled ? Theme.accentInk : Theme.textFaint
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// On a narrow window the hint would wrap into the editor's space.
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: !root.compact
|
|
text: {
|
|
if (root.running)
|
|
return "working — Esc cancels the turn"
|
|
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
|
|
if (root.attachments.length > 0 && agent.seesImages() === 0)
|
|
s += " · this model is text-only, images arrive as notes"
|
|
return s
|
|
}
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
|
|
FileDialog {
|
|
id: picker
|
|
title: "Attach a file"
|
|
fileMode: FileDialog.OpenFile
|
|
onAccepted: root.attachPath(root.localPath(selectedFile))
|
|
}
|
|
}
|