aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/assistant/Assistant.qml20
-rw-r--r--desktop/assistant/Card.qml20
-rw-r--r--desktop/assistant/History.qml5
-rw-r--r--desktop/assistant/Md.js8
-rw-r--r--desktop/assistant/README.md5
-rw-r--r--desktop/assistant/Segments.qml8
-rw-r--r--desktop/assistant/test_md.js4
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.