diff --git a/app/README.md b/app/README.md index c43361b..04f2a11 100644 --- a/app/README.md +++ b/app/README.md @@ -148,6 +148,33 @@ into the field reaches the engine when "Use this" is clicked, the note says so, and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch directory, so settings and keys of the real user are never rewritten. +## Attachments + +The next prompt can carry files, and they arrive three ways: + +- **๐Ÿ“Ž in the composer** opens a file picker (`QtQuick.Dialogs`). +- **Dropping files on the window** โ€” a `DropArea` covers it; while something is + dragged over, the border turns accent and says "drop to attach". +- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own + paste: the composer asks `AgentCore::clipboardHasImage()` first and only + intercepts the keystroke when there is an image. + +`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` / +`cst_agent_attach_bytes()`: the engine validates the file, copies it next to +the session (`.agent/sessions/`), and the next `run_turn` carries it โ€” the +window only shows what the engine's `pending` list says, one chip per file in +the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all" +(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away +with the turn. When the model cannot be shown images +(`cst_agent_sees_images() == 0`), the composer says so and the engine sends +them as text notes instead. + +`--uitest` writes a file into its scratch workspace and checks the whole path: +`attachFile` succeeds, the engine reports one pending attachment, a missing file +is refused with a reason, the composer shows one chip, "clear all" empties the +list, and โ€” with an 8ร—8 image put on the clipboard โ€” `clipboardHasImage()` and +`attachClipboard()` behave. + ## Tools the app provides A tool implemented in the front end is registered with `cst_tool_register()` @@ -205,7 +232,8 @@ The build flag that keeps Unix helpers out of the picture is `--uitest` for mouse, touch and small-screen checks - [x] session list in the sidebar: new / switch / delete, transcript replay - [x] settings dialog: provider, model, key (`cst_ps_*`), token counters +- [x] attachments: picker, drop on the window, Ctrl+V image paste - [ ] a real turn driven through the window (needs a provider key) -- [ ] attachments (drag in, paste) +- [ ] tool results that carry images (they are stored, not drawn yet) - [ ] Kirigami (Breeze look), tray, global shortcuts - [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/Composer.qml b/app/qml/Composer.qml index 74a5ae1..68da609 100644 --- a/app/qml/Composer.qml +++ b/app/qml/Composer.qml @@ -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)) + } } diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 0988b07..91de097 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -326,18 +326,61 @@ ApplicationWindow { } Composer { + id: composer Layout.fillWidth: true Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL running: agent.running compact: win.compact + attachments: agent.pending onSubmitted: function (text) { win.submit(text) } } } } // ---- compact: the sidebar slides in over the transcript -------------- + /* Files dropped anywhere on the window: the engine copies them next to + * the session and the next prompt carries them. The picker and Ctrl+V in + * the composer land in the same list. */ + DropArea { + id: dropZone + objectName: "dropZone" + anchors.fill: parent + onDropped: function (drop) { + if (!drop.hasUrls) + return + let note = "" + let n = 0 + for (let i = 0; i < drop.urls.length; i++) { + const r = agent.attachFile(composer.localPath(drop.urls[i])) + if (r.length) + note = r + else + n++ + } + composer.note = note.length ? note + : n + (n === 1 ? " file attached" : " files attached") + drop.accept() + } + } + + // While something is dragged over the window, say what will happen. + Rectangle { + anchors.fill: parent + visible: dropZone.containsDrag + color: "transparent" + border.width: 2 + border.color: Theme.accent + Text { + anchors.centerIn: parent + text: "drop to attach" + color: Theme.accent + font.pixelSize: Theme.bodySize + font.bold: true + } + } + Rectangle { id: scrim objectName: "navScrim" diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 4f1ba4f..89fc512 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -1,8 +1,12 @@ #include "AgentCore.h" +#include +#include #include #include #include +#include +#include #include #include #include @@ -182,6 +186,9 @@ void AgentCore::finishTurn(int rc, const QString &err) { Turn *t = static_cast(sender()); post([this, rc, err] { setRunning(false); + /* The prompt carried whatever was pending; the engine dropped it. */ + m_attachNames.clear(); + emit pendingChanged(); emit turnFinished(rc, err); }); if (t) { @@ -664,6 +671,87 @@ QVariantMap AgentCore::usage() const { return m; } +/* --- attachments ---------------------------------------------------------- */ + +QStringList AgentCore::pendingAttachments() const { + QStringList out; + if (!m_agent) + return out; + const int n = cst_agent_pending_count(m_agent); + for (int i = 0; i < n; i++) { + char line[256] = ""; + if (cst_agent_pending_describe(m_agent, i, line, sizeof(line)) != 0) + continue; + /* The engine describes what the model gets ("png 12.0 KB"); the file it + * came from is the window's own business, so the name goes in front. */ + const QString desc = utf8(line); + out << (i < m_attachNames.size() + ? QStringLiteral("%1 โ€” %2").arg(m_attachNames.at(i), desc) + : desc); + } + return out; +} + +QString AgentCore::attachFile(const QString &path) { + if (!m_agent) + return QStringLiteral("no engine"); + if (path.isEmpty()) + return QStringLiteral("no file"); + char err[256] = ""; + if (cst_agent_attach_file(m_agent, path.toUtf8().constData(), err, + sizeof(err)) != 0) + return utf8(err[0] ? err : "that file cannot be attached"); + m_attachNames << QFileInfo(path).fileName(); + emit pendingChanged(); + return QString(); +} + +QString AgentCore::attachClipboard() { + if (!m_agent) + return QStringLiteral("no engine"); + QGuiApplication *gui = + qobject_cast(QCoreApplication::instance()); + if (!gui || !gui->clipboard()) + return QStringLiteral("no clipboard here"); + /* An image is what this is for: text belongs to the editor's own paste. */ + const QImage img = gui->clipboard()->image(); + if (img.isNull()) + return QStringLiteral("no image on the clipboard"); + QByteArray png; + QBuffer buf(&png); + buf.open(QIODevice::WriteOnly); + if (!img.save(&buf, "PNG")) + return QStringLiteral("cannot encode the clipboard image"); + char err[256] = ""; + if (cst_agent_attach_bytes(m_agent, "clipboard.png", png.constData(), + static_cast(png.size()), err, + sizeof(err)) != 0) + return utf8(err[0] ? err : "the clipboard image was refused"); + m_attachNames << QStringLiteral("clipboard.png"); + emit pendingChanged(); + return QString(); +} + +void AgentCore::clearPending() { + if (!m_agent) + return; + cst_agent_pending_clear(m_agent); + m_attachNames.clear(); + emit pendingChanged(); +} + +bool AgentCore::clipboardHasImage() const { + QGuiApplication *gui = + qobject_cast(QCoreApplication::instance()); + if (!gui || !gui->clipboard()) + return false; + return !gui->clipboard()->image().isNull(); +} + +int AgentCore::seesImages() const { + return m_agent ? cst_agent_sees_images(m_agent) : 0; +} + /* --- tools the front end provides ---------------------------------------- */ bool AgentCore::registerTool(const cst_tool_def_t *def) { diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index 2ddf0a8..4472ecc 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -52,7 +52,6 @@ class AgentCore : public QObject { bool planMode() const { return m_cfg.plan_mode != 0; } QString todo() const { return m_todo; } QString sessionId() const; - /* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a * message looks the same here as it does in the WebUI. */ Q_INVOKABLE QString renderMarkdown(const QString &text) const; @@ -92,6 +91,25 @@ class AgentCore : public QObject { /* Tokens of this session and of the last turn, plus the cost when priced. */ Q_INVOKABLE QVariantMap usage() const; + /* --- attachments (see cst_attach.h) ------------------------------------- */ + + Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged) + /* What the next prompt will carry: " (, 12 KB)" per line. */ + QStringList pendingAttachments() const; + /* Copy a file next to the session and give it to the next prompt. "" when + * it worked, else why not. */ + Q_INVOKABLE QString attachFile(const QString &path); + /* The same for an image on the clipboard (Ctrl+V). "" when it worked; a + * reason -- "no image on the clipboard" -- when the editor should have the + * keystroke instead. */ + Q_INVOKABLE QString attachClipboard(); + /* Whether Ctrl+V should become an attachment at all (an image is on the + * clipboard) or stay with the editor (text). */ + Q_INVOKABLE bool clipboardHasImage() const; + Q_INVOKABLE void clearPending(); + /* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */ + Q_INVOKABLE int seesImages() const; + /* The engine, for the parts that need it (tool registration). */ cst_agent_t *agent() const { return m_agent; } const cst_agent_config_t *config() const { return &m_cfg; } @@ -126,6 +144,7 @@ class AgentCore : public QObject { void planModeChanged(); void sessionChanged(); /* another session is open now */ void sessionListChanged(); /* the store gained, lost or renamed one */ + void pendingChanged(); /* attachments for the next prompt changed */ void statusChanged(const QString &text); void assistantDelta(const QString &text); void thinkDelta(const QString &text); @@ -185,6 +204,9 @@ class AgentCore : public QObject { bool m_permPending = false; QString m_question; QString m_todo; + /* Names of the files this window attached, in the engine's pending order: + * the engine's own description says what it is, not where it came from. */ + QStringList m_attachNames; QByteArray m_answer; bool m_questionPending = false; bool m_autoApprove = false; diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 533065d..a321c5a 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -8,6 +8,10 @@ #include "UiTest.h" +#include +#include +#include +#include #include #include #include @@ -330,6 +334,44 @@ int runUiTest(AgentCore &core, const QString &size) { check(false, QStringLiteral("the dialog has a model field")); } + /* --- attachments: a file goes next to the session -------------------- */ + const QString att = core.workspace() + QStringLiteral("/note.txt"); + { + QFile f(att); + f.open(QIODevice::WriteOnly); + f.write("hello from a fixture\n"); + } + check(core.attachFile(att).isEmpty(), + QStringLiteral("a file on disk can be attached")); + check(core.pendingAttachments().size() == 1, + QStringLiteral("the engine reports one attachment for the next prompt")); + check(core.pendingAttachments().first().contains(QLatin1String("note.txt")), + QStringLiteral("the attachment names the file it came from")); + check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(), + QStringLiteral("a missing file is refused with a reason")); + QTest::qWait(120); + check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1, + QStringLiteral("the composer lists it")); + maybeShot(win, QStringLiteral("-attach")); + clickMouse(win, QStringLiteral("clearAttachments")); + check(core.pendingAttachments().isEmpty(), + QStringLiteral("clear all drops them")); + + /* the paste path: an image on the clipboard becomes an attachment */ + QGuiApplication *gui = qobject_cast(QCoreApplication::instance()); + if (gui && gui->clipboard()) { + QImage img(8, 8, QImage::Format_RGB32); + img.fill(Qt::red); + gui->clipboard()->setImage(img); + check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen")); + check(core.attachClipboard().isEmpty() && + core.pendingAttachments().size() == 1, + QStringLiteral("an image on the clipboard is attached")); + core.clearPending(); + } else { + check(false, QStringLiteral("there is a clipboard to paste from")); + } + /* --- a narrow window: dock -> drawer, and the button opens it --- */ resizeTo(win, 460, 900); check(docked && !docked->isVisible(), diff --git a/include/cst_version.h b/include/cst_version.h index 6add24a..e2fdb70 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 11 +#define CST_VERSION_PATCH 12 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/attach.c b/src/attach.c index b5c89c4..ec4b812 100644 --- a/src/attach.c +++ b/src/attach.c @@ -618,9 +618,10 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) { if (a->bytes >= 1024 * 1024) snprintf(out, out_sz, "%s%s %.1f MB", dim, kind, (double)a->bytes / (1024 * 1024)); + else if (a->bytes >= 1024) + snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024); else - snprintf(out, out_sz, "%s%s %.0f KB", dim, kind, - (double)a->bytes / 1024); + snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes); } /* --- lifetime ------------------------------------------------------------ */