aboutsummaryrefslogtreecommitdiffstats
path: root/desktop
diff options
context:
space:
mode:
Diffstat (limited to 'desktop')
-rw-r--r--desktop/assistant/Assistant.qml46
-rw-r--r--desktop/assistant/Card.qml66
-rw-r--r--desktop/assistant/Models.qml87
3 files changed, 186 insertions, 13 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index 2641a5e..0b7026d 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -66,6 +66,18 @@ Scope {
// The preferred model being loaded on open, shown in the header until
// the router reports it loaded. Set from shell.qml.
property string swapping: ""
+ // The model this chat is asked with: its stored pick (hello.chat_model,
+ // conversation.model), "" for none, then the preferred one (set from
+ // shell.qml). "" for both: the daemon uses whatever the router has
+ // loaded. Spec: desktop-assistant,
+ // docs/superpowers/specs/2026-10-23-cloud-models-design.md.
+ property string chatModel: ""
+ property string preferred: ""
+ readonly property string wanted: root.chatModel || root.preferred
+ // The daemon's models event: [{id, status, cloud: false} | {id, provider, cloud: true}].
+ property var models: []
+ readonly property bool cloud: root.models.some(m => m.cloud && m.id === root.wanted)
+ property bool modelsOpen: false
property int ctxUsed: 0
property int ctxMax: 0
property var convId: null
@@ -113,11 +125,12 @@ Scope {
function close() {
root.endRecording(false);
- root.open = false; root.historyOpen = false; root.pickerOpen = false;
+ root.open = false; root.historyOpen = false; root.pickerOpen = false; root.modelsOpen = false;
}
// Escape: a recording, the picker, then the history panel, then the card.
function back() {
if (root.recording) root.endRecording(false);
+ else if (root.modelsOpen) root.modelsOpen = false;
else if (root.pickerOpen) root.pickerOpen = false;
else if (root.historyOpen) root.historyOpen = false;
else root.close();
@@ -258,8 +271,8 @@ Scope {
root.notice = "recording failed";
} else if (rec.send && root.connected) {
root.chatting = true;
- root.send({ type: "ask", text: "", audio: root.micPath,
- files: root.staged.map(f => f.path) });
+ root.send(root.withModel({ type: "ask", text: "", audio: root.micPath,
+ files: root.staged.map(f => f.path) }));
}
rec.destroy();
}
@@ -325,16 +338,37 @@ Scope {
|| (text.trim() === "" && !root.staged.length)) return false;
root.notice = "";
root.chatting = true;
- root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
+ root.send(root.withModel({ type: "ask", text: text, files: root.staged.map(f => f.path) }));
return true;
}
function showHistory() {
root.pickerOpen = false;
+ root.modelsOpen = false;
root.historyOpen = true;
root.send({ type: "list" });
}
+ function showModels() {
+ root.historyOpen = root.pickerOpen = false;
+ root.modelsOpen = true;
+ root.send({ type: "models" });
+ }
+ // Applies from the next ask; the daemon stores it with the chat then.
+ // Moving a chat with turns to the cloud sends its history there, so
+ // that is said once, on the switch.
+ function pickModel(id) {
+ const m = root.models.find(x => x.id === id);
+ if (m && m.cloud && !root.cloud && turns.count)
+ root.notice = `History in this chat goes to ${m.provider}.`;
+ root.chatModel = id;
+ root.modelsOpen = false;
+ }
+ function withModel(cmd) {
+ if (root.wanted) cmd.model = root.wanted;
+ return cmd;
+ }
+
function row(t, live) {
return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "",
tools: (t.tools ?? []).join(","), error: "", live: live,
@@ -371,15 +405,18 @@ Scope {
root.state = e.state;
root.runningTool = e.state === "tool" ? e.tool : "";
root.model = e.model ?? "";
+ root.chatModel = e.chat_model ?? "";
root.ctxUsed = e.ctx_used;
root.ctxMax = e.ctx_max;
root.load(e.id, e.turns, e.pending);
// A restart or reload counts as active: the conversation it
// reconnects to is not stale just because this process is new.
if (e.pending || e.turns.length) root.lastActive = Date.now();
+ root.send({ type: "models" });
break;
case "conversation":
root.load(e.id, e.turns, null);
+ root.chatModel = e.model ?? "";
// Opened from History on purpose: it counts as active now.
if (e.turns.length) root.lastActive = Date.now();
// Emptied (New, or the current one deleted) with nothing staged:
@@ -445,6 +482,7 @@ Scope {
case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; if (e.model) root.model = e.model; break;
case "conversations": root.convs = e.items; break;
case "results": root.results = e.items; break;
+ case "models": root.models = e.items; break;
}
}
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index a2f8e28..b9fe4e5 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -28,7 +28,7 @@ Rectangle {
property real fullHeight: 600 // the chat state's height, set by the window
readonly property string mode: card.canvas.mode
- readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen
+ readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen || card.canvas.modelsOpen
readonly property bool tall: card.mode === "chat" || card.panel
// Bottom-up: the input, then the actions (file state), then the chips.
@@ -49,13 +49,15 @@ Rectangle {
// Keys reach a focused item, never the window (AGENTS.md); the card is
// the ancestor of every text field, so their Escape bubbles here.
Keys.onEscapePressed: card.canvas.back()
- // Ctrl+N and Ctrl+H do what the New and History buttons do.
+ // Ctrl+N, Ctrl+H and Ctrl+M do what New, History and the model menu do.
Keys.onPressed: event => {
if (!(event.modifiers & Qt.ControlModifier)) return;
if (event.key === Qt.Key_N && card.canvas.connected) {
card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus();
} else if (event.key === Qt.Key_H) {
card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory();
+ } else if (event.key === Qt.Key_M && card.canvas.connected) {
+ card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels();
} else return;
event.accepted = true;
}
@@ -90,13 +92,40 @@ Rectangle {
anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 }
height: 32
- Text {
+ // The chat's model, a click opens the model panel. nf-fa-cloud for a
+ // cloud one, nf-fa-angle_down after it.
+ Row {
+ id: modelRow
anchors { left: parent.left; verticalCenter: parent.verticalCenter }
- text: !card.canvas.connected ? "assistant not running"
- : card.canvas.swapping ? `loading ${card.canvas.swapping}…`
- : card.canvas.model || "assistant"
- color: card.canvas.connected ? Theme.text : Theme.red
- font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ spacing: 6
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.connected && !card.canvas.swapping && card.canvas.cloud
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: !card.canvas.connected ? "assistant not running"
+ : card.canvas.swapping ? `loading ${card.canvas.swapping}…`
+ : card.canvas.wanted || card.canvas.model || "assistant"
+ color: card.canvas.connected ? Theme.text : Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.connected
+ text: ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+ MouseArea {
+ anchors.fill: modelRow
+ enabled: card.canvas.connected
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels()
}
Row {
@@ -466,7 +495,7 @@ Rectangle {
id: box
anchors {
bottom: parent.bottom; left: mic.right; leftMargin: 8
- right: history.left; rightMargin: 8
+ right: modelBtn.left; rightMargin: 8
}
// Grows with the text up to six lines, then scrolls.
height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16
@@ -524,6 +553,20 @@ Rectangle {
}
}
+ // The model menu outside chat, where there is no header; nf-fa-cloud
+ // when the next question goes to a cloud model.
+ Button {
+ id: modelBtn
+ visible: card.mode !== "chat"
+ width: visible ? implicitWidth : 0
+ anchors { bottom: parent.bottom; right: history.left; rightMargin: visible ? 8 : 0 }
+ height: box.height
+ icon: card.canvas.cloud ? "" : ""
+ text: "Model"
+ enabled: card.canvas.connected
+ onClicked: card.canvas.modelsOpen ? card.canvas.back() : card.canvas.showModels()
+ }
+
// History from every state; in chat the header has it too.
Button {
id: history
@@ -571,5 +614,10 @@ Rectangle {
visible: card.canvas.pickerOpen
canvas: card.canvas
}
+ Models {
+ anchors.fill: parent
+ visible: card.canvas.modelsOpen
+ canvas: card.canvas
+ }
}
}
diff --git a/desktop/assistant/Models.qml b/desktop/assistant/Models.qml
new file mode 100644
index 0000000..b1b2a38
--- /dev/null
+++ b/desktop/assistant/Models.qml
@@ -0,0 +1,87 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+
+// The model panel, over the transcript: the router's models, then the
+// cloud ones, in the daemon's order. A pick applies to this chat from its
+// next question.
+Rectangle {
+ id: panel
+ property var canvas
+
+ color: Qt.alpha(Theme.base, 0.97)
+ radius: 8
+
+ MouseArea { anchors.fill: parent } // keep clicks off the transcript below
+
+ ListView {
+ id: rows
+ anchors.fill: parent
+ clip: true
+ spacing: 2
+ model: panel.canvas.models
+
+ delegate: Rectangle {
+ id: r
+ required property var modelData
+ readonly property bool picked: r.modelData.id === panel.canvas.wanted
+ width: rows.width
+ height: 34
+ radius: 6
+ color: hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent"
+ border.width: r.picked ? 1 : 0
+ border.color: Qt.alpha(Theme.accent, 0.5)
+
+ HoverHandler { id: hover }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.canvas.pickModel(r.modelData.id)
+ }
+ // nf-fa-cloud, present in Inconsolata Nerd Font.
+ Text {
+ id: icon
+ anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter }
+ width: 16
+ text: r.modelData.cloud ? "" : ""
+ color: Theme.subtext
+ font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ anchors { left: icon.right; leftMargin: 6; right: stat.left; rightMargin: 8
+ verticalCenter: parent.verticalCenter }
+ // A cloud id without its provider: the right column says it.
+ text: r.modelData.cloud ? r.modelData.id.slice(r.modelData.provider.length + 1)
+ : r.modelData.id
+ elide: Text.ElideRight
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: r.picked }
+ }
+ Text {
+ id: stat
+ anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: r.modelData.cloud ? r.modelData.provider : r.modelData.status
+ color: r.modelData.status === "loaded" ? Theme.green : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: rows.count === 0
+ text: "No models: is llama-server running?"
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ }
+}