diff options
| -rw-r--r-- | desktop/assistant/Assistant.qml | 20 | ||||
| -rw-r--r-- | desktop/assistant/Card.qml | 20 | ||||
| -rw-r--r-- | desktop/assistant/History.qml | 5 | ||||
| -rw-r--r-- | desktop/assistant/Md.js | 8 | ||||
| -rw-r--r-- | desktop/assistant/README.md | 5 | ||||
| -rw-r--r-- | desktop/assistant/Segments.qml | 8 | ||||
| -rw-r--r-- | desktop/assistant/test_md.js | 4 |
7 files changed, 55 insertions, 15 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index a04a24b..d143219 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -38,6 +38,9 @@ Scope { property var results: [] readonly property bool busy: root.state !== "idle" + // A daemon gone mid-reply sends no idle; its hello on return redraws. + onConnectedChanged: if (!root.connected) { root.state = "idle"; root.runningTool = ""; } + // One row per turn. tools is a comma-joined string: a ListModel role // holding an array becomes a nested model, which a Repeater can read but // setProperty cannot append to. @@ -107,6 +110,7 @@ Scope { switch (e.type) { case "hello": root.state = e.state; + root.runningTool = e.state === "tool" ? e.tool : ""; root.model = e.model ?? ""; root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; @@ -122,11 +126,10 @@ Scope { case "token": root.bufReply += e.delta; break; case "tool": root.flush(); - if (!e.done && i >= 0) { + if (!e.done && i >= 0 && turns.get(i).live) { const t = turns.get(i).tools; turns.setProperty(i, "tools", t ? t + "," + e.name : e.name); } - root.runningTool = e.done ? "" : e.name; break; case "done": root.flush(); @@ -135,19 +138,22 @@ Scope { turns.setProperty(i, "stopped", e.stopped === true); } if (!e.stopped) root.convId = e.id; - root.runningTool = ""; break; case "error": - root.flush(); - if (i >= 0 && turns.get(i).live) { + // Only an error marked turn ended the reply. A command error, + // perhaps another client's, leaves a streaming reply alone. + if (e.turn && i >= 0 && turns.get(i).live) { + root.flush(); turns.setProperty(i, "error", e.message); turns.setProperty(i, "live", false); } else { root.notice = e.message; } - root.runningTool = ""; break; - case "state": root.state = e.state; break; + case "state": + root.state = e.state; + root.runningTool = e.state === "tool" ? e.tool : ""; + break; case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; break; case "conversations": root.convs = e.items; break; case "results": root.results = e.items; break; diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index 543edd1..ab80d1b 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -34,6 +34,21 @@ Rectangle { Component.onCompleted: input.forceActiveFocus() + // Focus falls out of the card when its holder goes: the history field + // hiding, or a streaming reply's text rebuilt under a click. With the + // keyboard grabbed, nothing would then reach Escape. Give it back. + Connections { + target: card.Window.window + function onActiveFocusItemChanged() { + const f = card.Window.activeFocusItem; + if (!f || f === card.Window.contentItem) Qt.callLater(() => input.forceActiveFocus()); + } + } + Connections { + target: card.canvas + function onHistoryOpenChanged() { if (!card.canvas.historyOpen) input.forceActiveFocus(); } + } + // --- header --- Item { @@ -91,6 +106,7 @@ Rectangle { // Follow the bottom while a reply streams, unless the user scrolled // up to read; scrolling back to the end resumes following. property bool stick: true + onMovementStarted: stick = false onMovementEnded: stick = atYEnd onContentHeightChanged: if (stick) Qt.callLater(positionViewAtEnd) Component.onCompleted: positionViewAtEnd() @@ -153,7 +169,9 @@ Rectangle { width: scroller.width wrapMode: TextEdit.Wrap selectByMouse: true - enabled: card.canvas.connected + // readOnly, not disabled: a disabled item cannot hold the + // focus that Escape needs to reach the card. + readOnly: !card.canvas.connected color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } onCursorRectangleChanged: scroller.follow(cursorRectangle) diff --git a/desktop/assistant/History.qml b/desktop/assistant/History.qml index 114ab2b..709e1a6 100644 --- a/desktop/assistant/History.qml +++ b/desktop/assistant/History.qml @@ -92,8 +92,9 @@ Rectangle { // A list row has its date; a search row its snippet. text: r.modelData.snippet !== undefined // Backticks go: a match inside a code span would - // show its ** markers literally. - ? r.modelData.snippet.replace(/\s+/g, " ").replace(/`/g, "") + // show its ** markers literally. Sanitized like any + // model output: a snippet can hold a remote image. + ? Notify.sanitize(r.modelData.snippet.replace(/\s+/g, " ").replace(/`/g, "")) : new Date(r.modelData.updated * 1000).toLocaleString(Qt.locale(), Locale.ShortFormat) textFormat: r.modelData.snippet !== undefined ? Text.MarkdownText : Text.PlainText elide: Text.ElideRight diff --git a/desktop/assistant/Md.js b/desktop/assistant/Md.js index fe61522..dd58ede 100644 --- a/desktop/assistant/Md.js +++ b/desktop/assistant/Md.js @@ -39,10 +39,12 @@ function split(text) { } continue; } - const open = /^(\s*)(`{3,}|~{3,})\s*([^`\s]*)/.exec(line); - if (open) { + // A backtick fence's info string has no backtick (CommonMark), so + // a line opening with inline ```code``` stays prose. + const open = /^(\s*)(`{3,}|~{3,})(.*)$/.exec(line); + if (open && !(open[2][0] === "`" && open[3].includes("`"))) { flush(); - code = { indent: open[1], fence: open[2], lang: open[3], lines: [] }; + code = { indent: open[1], fence: open[2], lang: open[3].trim().split(/\s+/)[0], lines: [] }; } else { prose.push(line); } diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md index 0f80993..8fc200b 100644 --- a/desktop/assistant/README.md +++ b/desktop/assistant/README.md @@ -40,6 +40,11 @@ Notes: `ServerNotFoundError` warning each time. - **`tools` is a comma-joined string role.** A ListModel role holding an array becomes a nested model, which `setProperty` cannot append to. +- **A stopped reply is shown, not kept.** The daemon does not save a + partial turn, so it is gone after the next reopen or restart. +- **Only an error marked `turn` ends a reply.** Every client sees every + event, so a command error (another canvas opening a deleted + conversation) shows above the input instead. - **Search snippets** mark matches with `**`, rendered as bold; backticks are stripped first, since a match inside a code span would show the markers literally. diff --git a/desktop/assistant/Segments.qml b/desktop/assistant/Segments.qml index 2b68b64..cd216df 100644 --- a/desktop/assistant/Segments.qml +++ b/desktop/assistant/Segments.qml @@ -45,7 +45,9 @@ Column { color: Theme.text selectionColor: Qt.alpha(Theme.accent, 0.4) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } - onLinkActivated: link => Qt.openUrlExternally(link) + // Web and mail links only: a model-written file: or custom + // scheme link would be handed to xdg-open. + onLinkActivated: link => { if (/^(https?|mailto):/i.test(link)) Qt.openUrlExternally(link); } HoverHandler { cursorShape: prose.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor } } @@ -77,7 +79,9 @@ Column { anchors { right: parent.right; top: parent.top; margins: 6 } visible: !part.modelData.open width: visible ? implicitWidth : 0 - text: copied.running ? "Copied" : (part.modelData.lang || "Copy") + text: copied.running ? "Copied" : "Copy" + // ponytail: one argv string, so a block over 128 KiB + // (MAX_ARG_STRLEN) fails to copy; stdin if that matters. onClicked: { Quickshell.execDetached(["wl-copy", "--", part.modelData.text]); copied.restart(); diff --git a/desktop/assistant/test_md.js b/desktop/assistant/test_md.js index 030cdc9..58bf974 100644 --- a/desktop/assistant/test_md.js +++ b/desktop/assistant/test_md.js @@ -43,6 +43,10 @@ assert.deepStrictEqual(split("1. Install:\n ```sh\n make\n make install\ [prose("1. Install:"), code("sh", "make\n make install"), prose("2. Go")]); // A shorter or different closing run does not close the fence. assert.deepStrictEqual(split("````\n```\n~~~\n````"), [code("", "```\n~~~")]); +// Inline triple-backtick code at a line start is not a fence. +assert.deepStrictEqual(split("```x``` is inline\nmore"), [prose("```x``` is inline\nmore")]); +// Words after the language are ignored. +assert.deepStrictEqual(split("```python title=a\nx\n```"), [code("python", "x")]); // Tilde fences. assert.deepStrictEqual(split("~~~js\nx\n~~~"), [code("js", "x")]); // A closing fence with text after it is content, not a close. |
