v0.8.12: attachments in the composer, picker + drop + paste

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.
This commit is contained in:
WHC
2026-10-11 12:35:22 +08:00
parent be4025e276
commit cbd71b83c7
8 changed files with 367 additions and 8 deletions
+138 -3
View File
@@ -1,15 +1,22 @@
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)
@@ -21,6 +28,22 @@ Rectangle {
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
@@ -37,6 +60,70 @@ Rectangle {
}
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
@@ -69,10 +156,46 @@ Rectangle {
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"
@@ -103,11 +226,23 @@ Rectangle {
Text {
Layout.fillWidth: true
visible: !root.compact
text: root.running
? "working — Esc cancels the turn"
: "Enter sends · Shift+Enter newline · Esc stops · every change asks first"
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))
}
}