A prompt can carry files now, and they come in the three ways a desktop user expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files dropped anywhere on the window (a DropArea over it, with an accent border and "drop to attach" while something is dragged), and an image on the clipboard (Ctrl+V). Text keeps the editor's own paste: the composer asks AgentCore::clipboardHasImage() first and only takes the keystroke when there is an image. AgentCore (app/src/core/AgentCore.*): - pending is a property now (QStringList) fed by cst_agent_pending_describe(); attachFile() and attachClipboard() wrap cst_agent_attach_file() / cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and seesImages() reports whether the model is shown images at all. - the engine's description says what the model gets ("png 12.0 KB"), not where it came from, so the window remembers the names it attached and puts them in front: "note.txt — text 21 B". - pendingChanged() fires on attach, on clear and when a turn ends (the prompt consumed the list), which is what makes the chips move. Engine (src/attach.c): - cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB" now, and test_attach still passes. QML: the composer shows one chip per pending file with "clear all"; the hint line mentions 📎/Ctrl+V and says when the model is text-only (images then travel as notes); Main.qml owns the DropArea and the drag feedback. Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a missing file is refused, one chip in the composer, clear all empties it, the clipboard image is seen and attached), --selftest, make check rc=0 (test_attach included), and screenshots of the chip row. Version 0.8.12.
249 lines
9.2 KiB
QML
249 lines
9.2 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 📎 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 || running)
|
|
return
|
|
input.text = ""
|
|
submitted(t)
|
|
}
|
|
|
|
/* A file: from the picker, or a URL dropped on the window. */
|
|
function localPath(url) {
|
|
let s = String(url)
|
|
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
|
|
implicitHeight: layout.implicitHeight + Theme.pad
|
|
|
|
ColumnLayout {
|
|
id: layout
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: parent.top
|
|
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
|
|
? "the turn is running — Esc stops it"
|
|
: (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: !root.running
|
|
|
|
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: Text {
|
|
text: "📎"
|
|
font.pixelSize: 16
|
|
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
|
: Theme.textFaint
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
|
: "transparent"
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
}
|
|
onClicked: picker.open()
|
|
}
|
|
|
|
Button {
|
|
id: action
|
|
objectName: "sendButton"
|
|
Layout.alignment: Qt.AlignBottom
|
|
implicitWidth: root.compact ? 68 : 88
|
|
implicitHeight: Theme.touchTarget + 4
|
|
text: root.running ? "Stop" : "Send"
|
|
enabled: root.running || input.text.trim().length > 0
|
|
onClicked: root.running ? 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 ? "#12161a" : 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 · 📎 or Ctrl+V attaches"
|
|
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))
|
|
}
|
|
}
|