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 || 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) // 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 ? "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: 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 ? "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 ? 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)) } }