"
+ : ""
+ color: Theme.surfaceText
+ font.family: "monospace"
+ font.pixelSize: 12
+ background: null
+ leftPadding: 0
+ rightPadding: 0
+ topPadding: 0
+ bottomPadding: 0
+ }
+ }
+
+ // Sticky language bar: declared after the code so it sits
+ // on top while code slides under it.
+ Rectangle {
+ id: langBar
+ x: 0
+ y: codeRect.headerY
+ width: parent.width
+ height: codeRect.headerH
+ radius: 8
+ color: Theme.surfaceContainerHigh
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 4
+
+ Text {
+ visible: codeRect.seg && codeRect.seg.lang.length > 0
+ text: codeRect.seg ? codeRect.seg.lang : ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: 13
+ }
+ Item { Layout.fillWidth: true }
+ HoverIcon {
+ iconName: "content_copy"
+ iconColor: Theme.surfaceVariantText
+ onClicked: Quickshell.execDetached(
+ ["wl-copy", "--", codeRect.rawCode])
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: thinkingBox
+ ColumnLayout {
+ id: thinkPane
+ spacing: 4
+ // Thinking header chip: hovering tints it with the accent
+ // color and crossfades the bulb into an expand arrow; the
+ // whole chip is the toggle target. Hidden when the model
+ // produced no thinking — the box then hosts only the
+ // tool chips below.
+ Rectangle {
+ id: thinkHeader
+ visible: thinking.length > 0
+ Layout.fillWidth: false
+ implicitWidth: headerRow.implicitWidth + 16
+ implicitHeight: headerRow.implicitHeight + 8
+ radius: 8
+ color: thinkHeaderHover.hovered
+ ? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
+ Behavior on color { ColorAnimation { duration: 220 } }
+
+ HoverHandler {
+ id: thinkHeaderHover
+ cursorShape: Qt.PointingHandCursor
+ }
+ TapHandler {
+ onTapped: bubble.thinkingExpanded =
+ !bubble.thinkingExpanded
+ }
+
+ RowLayout {
+ id: headerRow
+ anchors.centerIn: parent
+ spacing: 6
+
+ Item {
+ width: 16; height: 16
+ DankIcon {
+ anchors.centerIn: parent
+ name: "emoji_objects"
+ size: 16
+ color: Theme.surfaceVariantText
+ opacity: thinkHeaderHover.hovered ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: 220 }
+ }
+ }
+ DankIcon {
+ anchors.centerIn: parent
+ name: "expand_more"
+ size: 16
+ color: Theme.primary
+ opacity: thinkHeaderHover.hovered ? 1 : 0
+ Behavior on opacity {
+ NumberAnimation { duration: 220 }
+ }
+ }
+ }
+
+ Text {
+ text: !bubble.thinkingDone ? "Pondering..."
+ : (bubble.thinkingExpanded ? "Hide thinking"
+ : bubble.ponderedLabel())
+ color: thinkHeaderHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: 11
+ Behavior on color { ColorAnimation { duration: 220 } }
+ }
+ }
+ }
+
+ // Thinking content: 220px cap + smoothed height; auto-scroll
+ // pinned to the bottom while streaming, free after. Column
+ // so tool chips render inside the collapsible area.
+ Flickable {
+ id: thinkFlick
+ // Hidden when collapsed — unless there's no thinking
+ // text and the box hosts only the tool chips.
+ visible: bubble.thinkingExpanded || thinking.length === 0
+ Layout.fillWidth: true
+ Layout.preferredHeight: smoothH
+ readonly property int maxH: 220
+ property real smoothH: Math.min(contentHeight, maxH)
+ Behavior on smoothH {
+ NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
+ }
+ contentHeight: thinkContent.implicitHeight
+ clip: true
+
+ property bool pinned: true
+
+ function chase() {
+ if (!pinned || !bubble.isStreaming) return;
+ if (height <= 0) return;
+ // Scroll ONLY on real overflow of the capped box.
+ // While the box is still growing, growth itself
+ // reveals the new text — chasing here would scroll
+ // content that the height animation is about to
+ // reveal (the per-flush nudge).
+ if (contentHeight - maxH < 10) return;
+ var target = contentHeight - maxH;
+ // huge backlog: snap (safe — plain Flickable has no
+ // item-anchor machinery to fight)
+ if (target - contentY > height) {
+ contentY = target;
+ return;
+ }
+ if (Math.abs(contentY - target) < 0.5) return;
+ thinkChaseAnim.to = target;
+ thinkChaseAnim.restart();
+ }
+
+ function unpin() {
+ pinned = false;
+ thinkChaseAnim.stop();
+ }
+
+ onContentHeightChanged: chase()
+
+ onDraggingChanged: if (dragging) unpin()
+ onFlickingChanged: if (flicking) unpin()
+ onContentYChanged: {
+ // the chase only ever increases contentY; any
+ // decrease is the user scrolling up
+ if (contentY < thinkFlick._lastY - 2) unpin();
+ thinkFlick._lastY = contentY;
+ if (atYEnd) pinned = true;
+ }
+ property real _lastY: 0
+
+ NumberAnimation {
+ id: thinkChaseAnim
+ target: thinkFlick
+ property: "contentY"
+ // Linear conveyor, same rationale as the message
+ // list follow: eased restarts spike per flush.
+ duration: 200
+ easing.type: Easing.Linear
+ }
+
+ WheelHandler {
+ onWheel: (event) => {
+ thinkFlick.unpin();
+ event.accepted = false;
+ }
+ }
+
+ // Box content: the pondering flow as interleaved
+ // segments — thinking text with the tool chips placed
+ // at the chronological point each call was issued:
+ // [text] [chip] [text] [chip] [live tail]. Everything
+ // lives inside the collapsible area, so it expands and
+ // collapses together with the box.
+ Column {
+ id: thinkContent
+ width: thinkFlick.width
+ spacing: 8
+
+ // Count-based model: delegates are reused while
+ // the count is stable; each reads its segment by
+ // index (same pattern as the body segments).
+ Repeater {
+ model: bubble.thinkSegments.length
+
+ delegate: Loader {
+ id: segLoader
+ required property int index
+ readonly property var seg:
+ (bubble.thinkSegments
+ && index < bubble.thinkSegments.length)
+ ? bubble.thinkSegments[index] : null
+ width: thinkContent.width
+ sourceComponent:
+ seg && seg.type === "tool"
+ ? toolChipComp : thinkTextSegComp
+ onLoaded: {
+ if (seg && seg.type === "tool") {
+ item.index = Qt.binding(function () {
+ return segLoader.seg ? segLoader.seg.idx : 0;
+ });
+ item.entry = Qt.binding(function () {
+ var s = segLoader.seg;
+ return s ? (bubble.toolLogArr[s.idx] || null)
+ : null;
+ });
+ } else {
+ // Text segment: hand the segment
+ // record to the TextArea so it can
+ // switch between the live fade html
+ // and the static markdown render.
+ item.seg = Qt.binding(function () {
+ return segLoader.seg;
+ });
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: thinkTextSegComp
+ TextArea {
+ property var seg: null
+ width: thinkContent.width
+ readOnly: true
+ textFormat: TextEdit.RichText
+ wrapMode: TextEdit.Wrap
+ // The live tail streams through the fade/
+ // reveal machinery; frozen heads render static
+ // markdown (their round is complete).
+ text: seg && seg.live
+ ? bubble.renderedThinkingHtml
+ : bubble.staticThinkHtml(seg ? seg.text : "")
+ color: Theme.surfaceTextMedium
+ font.pixelSize: 12
+ background: null
+ leftPadding: 0
+ rightPadding: 0
+ topPadding: 0
+ bottomPadding: 0
+ }
+ }
+ }
+
+ // One tool chip + its collapsible detail block. Generic
+ // label ("Calling Tool" / "Tool called for N seconds");
+ // the actual tool name lives in the detail. index/entry are
+ // bound by the instantiating Loader so the chip updates
+ // live as the entry mutates.
+ Component {
+ id: toolChipComp
+
+ ColumnLayout {
+ id: toolEntry
+ property int index: 0
+ property var entry: null
+ readonly property bool expanded: bubble.isToolExpanded(index)
+ readonly property bool hasDetail:
+ entry && entry.detail && entry.detail.length > 0
+ // Sub-second calls collapse to "(less than a
+ // second)" so the chip never shows 0 seconds.
+ readonly property string chipLabel: {
+ var e = toolEntry.entry;
+ if (!e || e.state === "running") return "Calling Tool";
+ var ms = Math.max(0,
+ (e.endMs || 0) - (e.startMs || 0));
+ if (ms < 1000) return "Tool called (less than a second)";
+ return "Tool called for " + Math.round(ms / 1000) + " seconds";
+ }
+
+ Layout.fillWidth: true
+ spacing: 2
+
+ // Tool chip: generic label (real name is in the
+ // detail); hover crossfades the icon to a
+ // chevron; accent "breathing" while in flight.
+ Rectangle {
+ id: toolChip
+ Layout.fillWidth: false
+ implicitWidth: toolChipRow.implicitWidth + 16
+ implicitHeight: toolChipRow.implicitHeight + 8
+ radius: 8
+ color: chipHover.hovered
+ ? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
+ Behavior on color { ColorAnimation { duration: 220 } }
+
+ // 0 → 1 → 0, only while the call is in
+ // flight. Lerps text/icon color toward
+ // the accent for the "breathing" effect.
+ property real breathe: 0
+ SequentialAnimation on breathe {
+ running: toolEntry.entry
+ && toolEntry.entry.state === "running"
+ loops: Animation.Infinite
+ NumberAnimation {
+ from: 0; to: 1; duration: 750
+ easing.type: Easing.InOutSine
+ }
+ NumberAnimation {
+ from: 1; to: 0; duration: 750
+ easing.type: Easing.InOutSine
+ }
+ }
+
+ function breatheColor(base, accent) {
+ var k = toolChip.breathe * 0.55;
+ return Qt.rgba(
+ base.r + (accent.r - base.r) * k,
+ base.g + (accent.g - base.g) * k,
+ base.b + (accent.b - base.b) * k,
+ 1
+ );
+ }
+
+ // Icon follows the text color and breathes
+ // toward the accent while in flight.
+ function iconColor() {
+ var base = Theme.surfaceVariantText;
+ if (chipHover.hovered) return Theme.primary;
+ if (!toolEntry.entry
+ || toolEntry.entry.state !== "running")
+ return base;
+ return toolChip.breatheColor(base, Theme.primary);
+ }
+
+ HoverHandler { id: chipHover; cursorShape: Qt.PointingHandCursor }
+ TapHandler {
+ onTapped: if (toolEntry.hasDetail)
+ bubble.toggleToolExpand(toolEntry.index)
+ }
+
+ RowLayout {
+ id: toolChipRow
+ anchors.centerIn: parent
+ spacing: 6
+
+ // Left slot: handyman glyph crossfading
+ // to a chevron on hover.
+ Item {
+ width: 16; height: 16
+ DankIcon {
+ anchors.centerIn: parent
+ name: "handyman"
+ size: 14
+ color: toolChip.iconColor()
+ opacity: chipHover.hovered ? 0 : 1
+ Behavior on opacity {
+ NumberAnimation { duration: 220 }
+ }
+ }
+ DankIcon {
+ anchors.centerIn: parent
+ name: "expand_more"
+ size: 14
+ color: toolChip.iconColor()
+ opacity: chipHover.hovered ? 1 : 0
+ rotation: toolEntry.expanded ? 180 : 0
+ Behavior on opacity {
+ NumberAnimation { duration: 220 }
+ }
+ Behavior on rotation {
+ RotationAnimation {
+ duration: 200
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+ }
+
+ Text {
+ text: toolEntry.chipLabel
+ color: {
+ if (chipHover.hovered) return Theme.primary;
+ return toolChip.breathe
+ ? toolChip.breatheColor(
+ Theme.surfaceVariantText,
+ Theme.primary)
+ : Theme.surfaceVariantText;
+ }
+ font.pixelSize: 11
+ font.family: Theme.monoFontFamily
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ // Collapsible detail: real tool name + pretty-
+ // printed response. Flickable caps the height;
+ // body is NoWrap to keep the JSON indent intact.
+ ColumnLayout {
+ id: toolDetail
+ visible: toolEntry.expanded && toolEntry.hasDetail
+ Layout.fillWidth: true
+ Layout.topMargin: 2
+ spacing: 4
+
+ Text {
+ Layout.fillWidth: true
+ text: toolEntry.entry ? toolEntry.entry.name : ""
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ font.family: Theme.monoFontFamily
+ font.bold: true
+ elide: Text.ElideRight
+ }
+
+ Flickable {
+ id: toolFlick
+ Layout.fillWidth: true
+ property real smoothH:
+ Math.min(toolDetailBody.contentHeight, 260)
+ Behavior on smoothH {
+ NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
+ }
+ Layout.preferredHeight: smoothH
+ contentHeight: toolDetailBody.contentHeight
+ contentWidth: toolDetailBody.contentWidth
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ TextArea {
+ id: toolDetailBody
+ width: Math.max(toolFlick.width,
+ toolDetailBody.contentWidth)
+ readOnly: true
+ textFormat: TextEdit.PlainText
+ wrapMode: TextEdit.NoWrap
+ text: toolEntry.entry
+ ? bubble.formatToolDetail(toolEntry.entry.detail)
+ : ""
+ color: Theme.surfaceTextMedium
+ font.pixelSize: 12
+ font.family: Theme.monoFontFamily
+ background: null
+ leftPadding: 8
+ rightPadding: 8
+ topPadding: 8
+ bottomPadding: 8
+ selectByMouse: true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
diff --git a/src/components/MessageList.qml b/src/components/MessageList.qml
new file mode 100644
index 0000000..24b4d6b
--- /dev/null
+++ b/src/components/MessageList.qml
@@ -0,0 +1,227 @@
+import QtQuick
+import QtQuick.Controls
+import qs.Common
+import qs.Widgets
+
+ListView {
+ id: list
+
+ property var service: null
+ model: service ? service.messagesModel : null
+
+ clip: true
+ spacing: 10
+ boundsBehavior: Flickable.StopAtBounds
+
+ // Keep-alive window spans the full conversation: delegates are
+ // never destroyed at a cache boundary (finite buffers wobbled
+ // contentHeight and made bottom-follow flicker).
+ cacheBuffer: Math.max(500, contentHeight)
+
+ // ── Bottom-follow ─────────────────────────────────────────────
+ // While streaming (and the user hasn't taken over), a frame
+ // conveyor eases contentY toward the end. Drag/wheel/scroll-away
+ // unpins; returning to the end, the pill, or a new message
+ // re-pins. After the stream the view is user-owned.
+ property bool pinned: true
+ property real _peakY: 0
+ property real _lastY: 0
+ property real _lastTarget: 0
+ // frames left in a "force to end" re-assert window (pill press)
+ property int _assertTicks: 0
+
+ function unpin() {
+ pinned = false;
+ }
+
+ // New rows (message sent, chat cleared) always re-pin: submitting
+ // a message means you want to see the response.
+ onCountChanged: {
+ pinned = true;
+ _peakY = contentY;
+ _lastY = contentY;
+ _lastTarget = Math.max(0, contentHeight - height);
+ }
+
+ onDraggingChanged: if (dragging) unpin()
+ onFlickingChanged: if (flicking) unpin()
+
+ // Render-frame conveyor: perfectly uniform steps (synced to the
+ // render loop — a Timer races/mismatches flush and frame timing,
+ // which read as choppy nudging).
+ FrameAnimation {
+ running: (list.pinned && list.service && list.service.isStreaming)
+ || list._assertTicks > 0
+ onTriggered: list.step()
+ }
+
+ function step() {
+ var target = Math.max(0, contentHeight - height);
+ // Pill "force to end" window: pin contentY to the end for a
+ // few frames so any polish pass re-asserting a stale anchor
+ // is overridden.
+ if (_assertTicks > 0) {
+ contentY = target;
+ _peakY = target;
+ _lastY = target;
+ _lastTarget = target;
+ _assertTicks--;
+ return;
+ }
+ var dY = contentY - _lastY;
+ var dTarget = target - _lastTarget;
+ // The conveyor only increases contentY; a drop >8px with a
+ // still end means the user scrolled — hand over control.
+ // Re-pin via return-to-end or the pill.
+ if (dY < -8 && dTarget > -2) {
+ unpin();
+ return;
+ }
+ // Content shrank (thinking box collapse — the end itself moved
+ // up): re-anchor the high-water mark and keep following.
+ if (contentY < _peakY - 24 && target < _peakY - 24)
+ _peakY = target;
+ if (contentY > _peakY) _peakY = contentY;
+ var gap = target - contentY;
+ if (gap < 0.5) return;
+ contentY += Math.min(gap, gap * 0.15);
+ _lastY = contentY;
+ _lastTarget = target;
+ if (contentY > _peakY) _peakY = contentY;
+ }
+
+ onContentYChanged: if (atYEnd) pinned = true
+
+ // Streaming status strip: reserves space at the end of the
+ // content while a stream is live.
+ footer: Component {
+ Item {
+ width: list.width
+ height: list.service && list.service.isStreaming ? 34 : 0
+ }
+ }
+
+ // Streaming status chip on a solid surface — readable over text;
+ // sticks to the viewport bottom once its footer strip slips out.
+ // (Same pattern as the code block language bar.)
+ Rectangle {
+ id: statusRow
+ visible: list.service && list.service.isStreaming
+ && list.service.messageCount > 0
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ radius: 10
+ implicitWidth: statusInner.implicitWidth + 20
+ implicitHeight: statusInner.implicitHeight + 10
+ color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
+ y: Math.min(
+ list.contentHeight - list.contentY + 6,
+ list.height - height - 6)
+
+ Row {
+ id: statusInner
+ anchors.centerIn: parent
+ spacing: 8
+
+ HexSphereLogo {
+ radius: 11
+ strokeWidth: 1.5
+ subdivisions: 1
+ yawRate: 20
+ pitchRate: 8
+ interactive: false
+ anchors.verticalCenter: parent.verticalCenter
+ visible: statusRow.visible
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: list.service ? list.service.ponderingText : ""
+ visible: text.length > 0
+ color: Theme.surfaceVariantText
+ font.pixelSize: 11
+ }
+ }
+ }
+
+ // Stream ended: the view is user-owned; the pill takes over.
+ Connections {
+ target: list.service
+ function onIsStreamingChanged() {
+ if (list.service && !list.service.isStreaming)
+ list.pinned = false;
+ }
+ }
+
+ // Full-width wrapper: ListView resets delegate x during layout, so
+ // right-alignment must come from anchoring the bubble inside.
+ delegate: Item {
+ width: ListView.view ? ListView.view.width : 300
+ implicitHeight: bubbleItem.height
+
+ MessageBubble {
+ id: bubbleItem
+ anchors.right: parent.right
+ service: list.service
+ listView: list
+ msgId: model.id
+ role: model.role
+ content: model.content
+ thinking: model.thinking
+ toolLog: model.toolLog
+ attachments: model.attachments
+ modelUsed: model.modelUsed
+ modelProviderId: model.modelProviderId
+ msgState: model.state
+ stats: model.stats
+ msgTimestamp: model.timestamp
+ }
+ }
+
+ ScrollBar.vertical: DankScrollbar {
+ onPressedChanged: if (pressed) list.unpin()
+ }
+
+ // Scroll-to-end pill: shows while the user holds scroll control
+ // away from the end (streaming or not); re-pins the follow (or
+ // returns to the bottom after the stream).
+ Rectangle {
+ id: toEndButton
+ visible: !list.pinned && !list.atYEnd
+ && (list.contentHeight - list.height - list.contentY) > 80
+ && list.contentHeight > list.height + 40
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: 14
+ width: 34
+ height: 34
+ radius: 17
+ color: toEndHover.hovered
+ ? Theme.withAlpha(Theme.primary, 0.25)
+ : Theme.surfaceContainerHigh
+ border.width: 1
+ border.color: Theme.outlineVariant
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "arrow_downward"
+ size: 18
+ color: toEndHover.hovered ? Theme.primary : Theme.surfaceText
+ }
+
+ HoverHandler {
+ id: toEndHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: {
+ // re-pin and force to end through a short re-assert
+ // window (a plain write can be reverted by the view's
+ // stale anchor after churn)
+ list.pinned = true;
+ list._assertTicks = 15;
+ }
+ }
+ }
+}
diff --git a/src/components/ModelSelector.qml b/src/components/ModelSelector.qml
new file mode 100644
index 0000000..607409a
--- /dev/null
+++ b/src/components/ModelSelector.qml
@@ -0,0 +1,883 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Widgets
+import "../lib/ModelIcons.js" as ModelIcons
+import "../lib/FuzzyMatch.js" as FuzzyMatch
+
+ // Bottom-bar chip, two zones: active model + chevron (model picker:
+ // pinned first, then per-provider groups), and tune icon (params
+ // popup). Popups open upward; picking a model from another instance
+ // switches the active provider too.
+Rectangle {
+ id: selector
+
+ property var service: null
+
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
+ implicitHeight: 34
+
+ // No-op placeholder while the service is missing; keep the chip
+ // visible so the layout is stable.
+ readonly property string modelLabel:
+ (service && service.activeModel && service.activeModel.length > 0)
+ ? service.modelLabel(service.activeProviderId, service.activeModel)
+ : "set model"
+ // Brand stem of the model in use ("" when none is active).
+ readonly property string activeStem: {
+ // Unused on purpose: reading modelIconStems is the QML
+ // dependency that re-runs this binding when the startup icon
+ // scan lands — iconForModel also matches AUTO_RULES, a plain
+ // lib var that cannot notify on its own.
+ var _scan = service ? service.modelIconStems : null;
+ return (service && service.activeModel && service.activeModel.length > 0)
+ ? ModelIcons.iconForModel(service.activeModel,
+ service.activeInstance && service.activeInstance.type
+ ? String(service.activeInstance.type) : "")
+ : "";
+ }
+
+ // Provider groups the user collapsed in the picker (pid → true).
+ property var collapsedProviders: ({})
+ function toggleCollapsed(pid) {
+ var m = {};
+ for (var k in collapsedProviders) m[k] = collapsedProviders[k];
+ if (m[pid]) delete m[pid];
+ else m[pid] = true;
+ collapsedProviders = m;
+ }
+
+ // Flat rows for the picker list: header rows and model rows.
+ // Reads pinnedModels/providers (reassigned on every change) so the
+ // ListView model binding re-evaluates through the service's
+ // reassignment pattern. Stale pins (provider deleted) are skipped.
+ function buildRows(filter) {
+ if (!service) return [];
+ var f = String(filter || "").toLowerCase();
+ var rows = [];
+ var pins = service.pinnedModels || [];
+ for (var i = 0; i < pins.length; i++) {
+ var rec = pins[i];
+ if (!rec || !rec.providerId || !rec.model) continue;
+ var pinInst = service.getProvider(rec.providerId);
+ if (!pinInst || pinInst.enabled === false) continue;
+ if (rows.length === 0)
+ rows.push({ kind: "header", name: "Pinned", type: "" });
+ rows.push({ kind: "model", providerId: String(rec.providerId),
+ model: String(rec.model),
+ type: String(pinInst.type) });
+ }
+ var provs = service.providers || [];
+ for (var j = 0; j < provs.length; j++) {
+ var p = provs[j];
+ if (!p || !p.id) continue;
+ // Disabled providers vanish from the picker.
+ if (p.enabled === false) continue;
+ var checked = p.checkedModels || [];
+ if (checked.length === 0) continue;
+ var collapsed = collapsedProviders[String(p.id)] === true;
+ rows.push({ kind: "header",
+ name: p.name && p.name.length > 0 ? String(p.name) : String(p.id),
+ type: String(p.type), pid: String(p.id),
+ collapsed: collapsed });
+ if (collapsed && !f) continue; // search ignores collapse
+ for (var k = 0; k < checked.length; k++)
+ rows.push({ kind: "model", providerId: String(p.id),
+ model: String(checked[k]),
+ type: String(p.type) });
+ }
+
+ if (!f) return rows;
+ var out = [];
+ for (var r = 0; r < rows.length; r++) {
+ var row = rows[r];
+ if (row.kind === "header") { out.push(row); continue; }
+ var lbl = service.modelLabel(row.providerId, row.model);
+ if (FuzzyMatch.matches(f, row.model)
+ || FuzzyMatch.matches(f, String(lbl))) out.push(row);
+ }
+ // drop headers left without any following model row
+ var pruned = [];
+ for (r = 0; r < out.length; r++) {
+ if (out[r].kind === "model") { pruned.push(out[r]); continue; }
+ if (r + 1 < out.length && out[r + 1].kind === "model") pruned.push(out[r]);
+ }
+ return pruned;
+ }
+
+ // Model zone (left): label + chevron. Hover highlights the zone
+ // (not the tune section) and shows the pointing cursor.
+ Item {
+ id: modelArea
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: 12
+ width: modelRow.implicitWidth + 8
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 8
+ // Fade via opacity: interpolating color toward "transparent"
+ // drifts the RGB toward black and reads as a dark flash.
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
+ opacity: modelHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ RowLayout {
+ id: modelRow
+ anchors.centerIn: parent
+ spacing: 6
+
+ BrandIcon {
+ size: 16
+ stem: selector.activeStem
+ }
+
+ Text {
+ text: selector.modelLabel
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ Layout.maximumWidth: 180
+ }
+
+ DankIcon {
+ name: modelPicker.visible ? "expand_more" : "expand_less"
+ size: 16
+ color: Theme.surfaceVariantText
+ }
+ }
+
+ HoverHandler { id: modelHover; cursorShape: Qt.PointingHandCursor }
+
+ TapHandler {
+ onTapped: modelPicker.visible ? modelPicker.close() : modelPicker.open()
+ }
+ }
+
+ // Zone divider
+ Rectangle {
+ id: zoneDivider
+ anchors.left: modelArea.right
+ anchors.leftMargin: 6
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.margins: 8
+ width: 1
+ color: Theme.withAlpha(Theme.outlineVariant, 0.6)
+ }
+
+ // Tune zone (right): generation params
+ Item {
+ id: tuneArea
+ anchors.left: zoneDivider.right
+ anchors.leftMargin: 6
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 36
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "instant_mix"
+ size: 18
+ color: genPopup.visible || tuneHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 250 } }
+ }
+
+ HoverHandler { id: tuneHover; cursorShape: Qt.PointingHandCursor }
+
+ TapHandler {
+ onTapped: genPopup.visible ? genPopup.close() : genPopup.open()
+ }
+ }
+
+ // Width budget: left margin 12 + model area (row + 8) + divider
+ // (6 margin + 1 + 6 margin) + tune zone 36 + right padding 8.
+ implicitWidth: modelRow.implicitWidth + 77
+
+ // ── Unified model picker ─────────────────────────────────────
+ Popup {
+ id: modelPicker
+ y: -height - 8
+ width: Math.min(selector.width + 220, 380)
+ height: Math.min(640, contentItem.implicitHeight + 24)
+ padding: 8
+
+ background: Rectangle {
+ radius: 12
+ color: Theme.surfaceContainerHigh
+ border.color: Theme.outlineVariant
+ }
+
+ contentItem: ColumnLayout {
+ id: pickerCol
+ spacing: 8
+
+ // Make a manual entry the active model; if the id is not
+ // already in the active instance's checked list, persist it
+ // there too so it reappears next time.
+ function pickModel(id) {
+ if (!selector.service || id.length === 0) return;
+ var svc = selector.service;
+ var models = svc.activeModels();
+ var known = false;
+ for (var i = 0; i < models.length; i++)
+ if (models[i] === id) { known = true; break; }
+ if (!known)
+ svc.toggleModel(svc.activeProviderId, id);
+ svc.setActiveModel(id);
+ modelPicker.close();
+ }
+
+ // Cross-instance selection: switching provider resets the
+ // active model (setActiveProvider), so set it afterwards.
+ function selectModel(providerId, modelId) {
+ if (!selector.service || !modelId || modelId.length === 0) return;
+ selector.service.setActiveProvider(String(providerId));
+ selector.service.setActiveModel(String(modelId));
+ modelPicker.close();
+ }
+
+ ListView {
+ id: modelList
+ Layout.fillWidth: true
+ // Floor of 56 keeps the empty-state hint visible when
+ // no provider has checked models.
+ Layout.preferredHeight: Math.min(480, Math.max(contentHeight, 56))
+ clip: true
+ spacing: 2
+ model: selector.service
+ ? selector.buildRows(manualEntry.text) : []
+
+ delegate: Item {
+ id: rowDelegate
+ required property var modelData
+ readonly property bool isModelRow:
+ modelData && modelData.kind === "model"
+ readonly property string rowProviderId:
+ isModelRow ? String(modelData.providerId) : ""
+ readonly property string rowModelId:
+ isModelRow ? String(modelData.model) : ""
+ readonly property string rowIconStem: {
+ // Unused read — same dependency trick as
+ // activeStem above (subscribes to the icon scan).
+ var _scan = selector.service ? selector.service.modelIconStems : null;
+ return isModelRow
+ ? ModelIcons.iconForModel(rowModelId,
+ modelData && modelData.type ? String(modelData.type) : "")
+ : "";
+ }
+ readonly property string headerIconStem:
+ !isModelRow && modelData && modelData.type
+ ? ModelIcons.providerIcon(String(modelData.type)) : ""
+ readonly property string rowPid:
+ !isModelRow && modelData && modelData.pid
+ ? String(modelData.pid) : ""
+ readonly property bool rowCollapsed:
+ rowPid.length > 0
+ && selector.collapsedProviders[rowPid] === true
+ readonly property bool rowPinned:
+ selector.service && isModelRow
+ && selector.service.isPinned(rowProviderId, rowModelId)
+ readonly property bool rowActive:
+ selector.service && isModelRow
+ && rowProviderId === selector.service.activeProviderId
+ && rowModelId === selector.service.activeModel
+
+ width: modelList.width
+ height: isModelRow ? 44 : 26
+
+ // Group header (or the "Pinned" section label):
+ // provider logo + name; tappable to collapse the
+ // group (except the Pinned label).
+ Row {
+ visible: !rowDelegate.isModelRow
+ anchors.left: parent.left
+ anchors.leftMargin: 10
+ anchors.right: parent.right
+ anchors.rightMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ BrandIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ size: 14
+ stem: rowDelegate.headerIconStem
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: rowDelegate.isModelRow ? ""
+ : (rowDelegate.modelData ? String(rowDelegate.modelData.name) : "")
+ color: Theme.withAlpha(Theme.surfaceVariantText, 0.6)
+ font.pixelSize: 11
+ }
+
+ TapHandler {
+ onTapped: if (rowDelegate.rowPid.length > 0)
+ selector.toggleCollapsed(rowDelegate.rowPid)
+ }
+ HoverHandler {
+ id: headerHover
+ enabled: rowDelegate.rowPid.length > 0
+ cursorShape: Qt.PointingHandCursor
+ }
+ }
+
+ // Collapse chevron (anchored right; the Row is a
+ // positioner and can't host it).
+ DankIcon {
+ visible: !rowDelegate.isModelRow
+ && rowDelegate.rowPid.length > 0
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ name: rowDelegate.rowCollapsed ? "expand_more" : "expand_less"
+ size: 16
+ color: headerHover.hovered ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ ItemDelegate {
+ id: modelDelegate
+ visible: rowDelegate.isModelRow
+ width: parent.width
+ height: parent.height
+ hoverEnabled: true
+ background: Rectangle {
+ radius: 6
+ color: modelDelegate.hovered
+ ? Theme.withAlpha(Theme.primary, 0.14) : "transparent"
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+
+ // Select area: everything left of the pin zone,
+ // so a tap on the pin never also selects.
+ Item {
+ id: selectArea
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: pinZone.left
+ anchors.leftMargin: 26
+ anchors.rightMargin: 2
+
+ TapHandler {
+ onTapped: pickerCol.selectModel(
+ rowDelegate.rowProviderId,
+ rowDelegate.rowModelId)
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 8
+
+ BrandIcon {
+ Layout.alignment: Qt.AlignVCenter
+ size: 16
+ stem: rowDelegate.rowIconStem
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: selector.service
+ ? selector.service.modelLabel(
+ rowDelegate.rowProviderId,
+ rowDelegate.rowModelId)
+ : rowDelegate.rowModelId
+ color: modelDelegate.hovered ? Theme.primary : Theme.surfaceText
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ }
+ DankIcon {
+ visible: rowDelegate.rowActive
+ name: "check"
+ size: 16
+ color: Theme.primary
+ }
+ }
+ }
+
+ // Pin zone: own TapHandler, no overlap with the
+ // select area — toggling must not select. Hover
+ // tints the pin to the accent color.
+ Item {
+ id: pinZone
+ width: 32
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+
+ HoverHandler {
+ id: pinHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: if (selector.service && rowDelegate.isModelRow)
+ selector.service.togglePinned(
+ rowDelegate.rowProviderId,
+ rowDelegate.rowModelId)
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "push_pin"
+ size: 16
+ filled: rowDelegate.rowPinned
+ color: rowDelegate.rowPinned || pinHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 250 } }
+ }
+ }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: modelList.count === 0
+ text: manualEntry.text.length > 0
+ ? "No models match"
+ : "No models — enable some in settings"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ width: parent.width - 16
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 6
+ DankTextField {
+ id: manualEntry
+ Layout.fillWidth: true
+ placeholderText: "Search model"
+ leftIconName: "search"
+ showClearButton: true
+ }
+ DankButton {
+ iconName: "check"
+ text: ""
+ buttonHeight: 36
+ onClicked: pickerCol.pickModel(manualEntry.text.trim())
+ }
+ }
+ }
+ }
+
+ // ── Generation params (Common Settings) ──────────────────────
+
+ // Checkbox card: checkbox center-left; title top-right with the
+ // number box beside it; slider below. Content grays when unchecked.
+ // Values are handled in raw slider units; `multiplier` converts to
+ // the real-world value (temperature uses 0.01).
+ component SettingCard : Rectangle {
+ id: card
+
+ property string label: ""
+ property bool checked: false
+ property int rawValue: 0
+ property int rawMin: 0
+ property int rawMax: 100
+ property int rawStep: 1
+ property real multiplier: 1.0
+ property int decimals: 0
+ signal toggled(bool checked)
+ signal valueEdited(int rawValue)
+
+ // True while the card syncs the slider from rawValue — suppresses
+ // the slider's onValueChanged side effects (focus steal + echo).
+ property bool _syncing: false
+
+ // True when the point (card-relative) sits over one of the
+ // card's interactive children — taps there must NOT toggle
+ // the checkbox.
+ function overChild(child, p) {
+ if (!child || !child.visible) return false;
+ var tl = card.mapFromItem(child, 0, 0);
+ return p.x >= tl.x && p.x <= tl.x + child.width
+ && p.y >= tl.y && p.y <= tl.y + child.height;
+ }
+
+ function displayValue() {
+ return (rawValue * multiplier).toFixed(decimals);
+ }
+
+ width: parent ? parent.width : 260
+ implicitHeight: cardContent.implicitHeight + 8
+ radius: 6
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.22)
+
+ // Passive hover highlight: a soft wash that fades in under
+ // the pointer. HoverHandler only — input stays with the
+ // slider/checkbox/number box.
+ HoverHandler { id: cardHover }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.primary, 0.07)
+ opacity: cardHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 180 } }
+ }
+
+ // Check toggle feedback: the box pops once, like the MCP
+ // tool rows.
+ SequentialAnimation {
+ id: checkBounce
+ NumberAnimation {
+ target: checkIcon; property: "scale"
+ to: 1.15; duration: 140; easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ target: checkIcon; property: "scale"
+ to: 1.0; duration: 240; easing.type: Easing.InOutQuad
+ }
+ }
+
+ // Whole-card toggle: a tap anywhere that is not on the number
+ // box or the slider flips the checkbox (the checkbox graphic
+ // itself included — it has no separate handler).
+ TapHandler {
+ onTapped: (eventPoint) => {
+ if (card.overChild(valueBox, eventPoint.position)) return;
+ if (card.overChild(slider, eventPoint.position)) return;
+ checkBounce.restart();
+ card.toggled(!card.checked);
+ }
+ }
+
+ DankIcon {
+ id: checkIcon
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ name: card.checked ? "check_box" : "check_box_outline_blank"
+ size: 20
+ color: card.checked ? Theme.primary : Theme.surfaceVariantText
+ }
+
+ ColumnLayout {
+ id: cardContent
+ anchors.left: checkIcon.right
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ spacing: 2
+ opacity: card.checked ? 1.0 : 0.35
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+
+ RowLayout {
+ width: parent.width
+ spacing: 8
+
+ Text {
+ Layout.fillWidth: true
+ text: card.label
+ color: Theme.surfaceText
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+
+ // DankTextField hardcodes left alignment; this minimal
+ // field gives us right-aligned numeric input.
+ Rectangle {
+ id: valueBox
+ Layout.preferredWidth: 76
+ Layout.preferredHeight: 24
+ radius: 4
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
+ border.width: 1
+ border.color: valueInput.activeFocus ? Theme.primary : Theme.outlineVariant
+ enabled: card.checked
+
+ TextInput {
+ id: valueInput
+ anchors.fill: parent
+ anchors.margins: 4
+ verticalAlignment: TextInput.AlignVCenter
+ horizontalAlignment: TextInput.AlignRight
+ text: card.displayValue()
+ color: Theme.surfaceText
+ font.pixelSize: 12
+ clip: true
+ selectByMouse: true
+ // NOTE: never write card.rawValue here — it would
+ // break rawValue's binding to the service and the
+ // slider would snap back. Push through valueEdited;
+ // the rawValue binding updates the card.
+ onTextChanged: if (activeFocus && !isNaN(parseFloat(text))) {
+ card.valueEdited(Math.round(parseFloat(text) / card.multiplier));
+ }
+ }
+ }
+ }
+
+ BallSlider {
+ id: slider
+ Layout.fillWidth: true
+ Layout.preferredHeight: 20
+ enabled: card.checked
+ minimum: card.rawMin
+ maximum: card.rawMax
+ step: card.rawStep
+ // No `value:` binding — the slider writes value
+ // imperatively on drag, which would break it. Sync both
+ // directions via onValueChanged + onRawValueChanged.
+ Component.onCompleted: value = card.rawValue
+ onValueChanged: {
+ if (card._syncing) return;
+ // Clicking the number box gives it activeFocus; a
+ // plain slider drag never clears it (the slider is
+ // not a focus scope), which would freeze the text.
+ // Steal focus back and always sync while dragging.
+ valueInput.focus = false;
+ card.valueEdited(value);
+ valueInput.text = card.displayValue();
+ }
+ }
+ }
+
+ Connections {
+ target: card
+ function onRawValueChanged() {
+ card._syncing = true;
+ slider.value = card.rawValue;
+ card._syncing = false;
+ if (!valueInput.activeFocus)
+ valueInput.text = card.displayValue();
+ }
+ }
+ }
+
+ // Local slider with a round thumb (DMS's DankSlider uses a bar
+ // handle, and shell widgets are read-only for plugins). Mirrors
+ // DankSlider's contract: int `value` written imperatively during
+ // drag, same position→value math and wheel stepping.
+ component BallSlider : Item {
+ id: bs
+
+ property int value: 50
+ property int minimum: 0
+ property int maximum: 100
+ property int step: 1
+
+ implicitHeight: 20
+
+ function updateValueFromPosition(x) {
+ var travel = Math.max(1, track.width - ball.width);
+ var ratio = Math.max(0, Math.min(1, (x - ball.width / 2) / travel));
+ var raw = minimum + ratio * (maximum - minimum);
+ var v = step > 1 ? Math.round(raw / step) * step : Math.round(raw);
+ v = Math.max(minimum, Math.min(maximum, v));
+ if (v !== value) value = v;
+ }
+
+ // Track line.
+ Rectangle {
+ id: track
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ height: 4
+ radius: 2
+ color: bs.enabled
+ ? Theme.withAlpha(Theme.outlineVariant, 0.9)
+ : Theme.withAlpha(Theme.outlineVariant, 0.4)
+ }
+
+ // Fill from the left edge to the ball's center.
+ Rectangle {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ height: 4
+ radius: 2
+ width: Math.max(0, Math.min(track.width,
+ ball.x + ball.width / 2))
+ color: bs.enabled ? Theme.primary
+ : Theme.withAlpha(Theme.primary, 0.3)
+ }
+
+ // Ball thumb: grows slightly on hover/press.
+ Rectangle {
+ id: ball
+ width: 16
+ height: 16
+ radius: 8
+ anchors.verticalCenter: track.verticalCenter
+ x: {
+ var range = bs.maximum - bs.minimum;
+ var ratio = range === 0
+ ? 0 : (bs.value - bs.minimum) / range;
+ var travel = track.width - width;
+ return Math.max(0, Math.min(travel, travel * ratio));
+ }
+ color: bs.enabled ? Theme.primary
+ : Theme.withAlpha(Theme.primary, 0.3)
+ scale: area.pressed || area.containsMouse ? 1.15 : 1.0
+ Behavior on scale {
+ NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
+ }
+ }
+
+ MouseArea {
+ id: area
+ anchors.fill: parent
+ // Generous vertical hit area, like DankSlider's.
+ anchors.topMargin: -8
+ anchors.bottomMargin: -8
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ preventStealing: true
+ enabled: bs.enabled
+
+ onPressed: (mouse) => bs.updateValueFromPosition(mouse.x)
+ onPositionChanged: (mouse) => {
+ if (pressed) bs.updateValueFromPosition(mouse.x);
+ }
+ onWheel: (wheel) => {
+ var wheelStep = bs.step > 1
+ ? bs.step : Math.max(1, (bs.maximum - bs.minimum) / 100);
+ var v = wheel.angleDelta.y > 0
+ ? Math.min(bs.maximum, bs.value + wheelStep)
+ : Math.max(bs.minimum, bs.value - wheelStep);
+ if (bs.step > 1) v = Math.round(v / bs.step) * bs.step;
+ v = Math.round(v);
+ if (v !== bs.value) bs.value = v;
+ wheel.accepted = true;
+ }
+ }
+ }
+
+ Popup {
+ id: genPopup
+ // Chip sits at the panel's bottom-left: align the popup's left
+ // edge to the chip so it opens rightward, inside the panel.
+ x: 0
+ y: -height - 8
+ width: 320
+ height: Math.min(420, contentItem.implicitHeight + 24)
+ padding: 12
+ // Gold-standard popup motion: rises out of the tune chip —
+ // fade + subtle scale anchored at the bottom edge — with a
+ // quicker, quieter exit.
+ transformOrigin: Popup.Bottom
+
+ enter: Transition {
+ NumberAnimation {
+ property: "opacity"
+ from: 0; to: 1
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ NumberAnimation {
+ property: "scale"
+ from: 0.94; to: 1
+ duration: 180
+ easing.type: Easing.OutCubic
+ }
+ }
+ exit: Transition {
+ NumberAnimation {
+ property: "opacity"
+ to: 0
+ duration: 140
+ easing.type: Easing.InCubic
+ }
+ NumberAnimation {
+ property: "scale"
+ to: 0.96
+ duration: 140
+ easing.type: Easing.InCubic
+ }
+ }
+
+ background: Rectangle {
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainer, 0.97)
+ border.color: Theme.surfaceVariantAlpha
+ border.width: 1
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 6
+
+ Text {
+ text: "Common Settings"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ font.bold: true
+ }
+
+ SettingCard {
+ label: "Context Window Size"
+ visible: service ? service.activeIsOllama : false
+ rawMin: 1024
+ rawMax: 32768
+ rawStep: 1024
+ rawValue: service ? service.numCtx : 8192
+ checked: service ? service.useContextWindow : false
+ onToggled: (checked) => {
+ if (!service) return;
+ service.useContextWindow = checked;
+ service.saveSetting("useContextWindow", checked);
+ }
+ onValueEdited: (raw) => {
+ if (!service) return;
+ service.numCtx = raw;
+ service.saveSetting("numCtx", raw);
+ }
+ }
+
+ SettingCard {
+ label: "Temperature"
+ decimals: 2
+ multiplier: 0.01
+ rawMin: 0
+ rawMax: 200
+ rawStep: 5
+ rawValue: service ? Math.round(service.temperature * 100) : 70
+ checked: service ? service.useTemperature : false
+ onToggled: (checked) => {
+ if (!service) return;
+ service.useTemperature = checked;
+ service.saveSetting("useTemperature", checked);
+ }
+ onValueEdited: (raw) => {
+ if (!service) return;
+ service.temperature = raw * multiplier;
+ service.saveSetting("temperature", raw * multiplier);
+ }
+ }
+
+ SettingCard {
+ label: "Max Output Token"
+ rawMin: 0
+ rawMax: 16384
+ rawStep: 256
+ rawValue: service ? service.maxTokens : 4096
+ checked: service ? service.useMaxTokens : false
+ onToggled: (checked) => {
+ if (!service) return;
+ service.useMaxTokens = checked;
+ service.saveSetting("useMaxTokens", checked);
+ }
+ onValueEdited: (raw) => {
+ if (!service) return;
+ service.maxTokens = raw;
+ service.saveSetting("maxTokens", raw);
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/NativeToolsSection.qml b/src/components/NativeToolsSection.qml
new file mode 100644
index 0000000..1c73a6b
--- /dev/null
+++ b/src/components/NativeToolsSection.qml
@@ -0,0 +1,197 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Widgets
+
+// Native chat tools settings (web_search / webfetch): one toggle row
+// per tool. Per-tool switches only — the master switch lives on the
+// chat bar; rows dim and hint there when it is off.
+Item {
+ id: root
+
+ property var service: null
+
+ readonly property bool masterOn: service ? service.webToolsEnabled : false
+
+ implicitHeight: body.implicitHeight
+
+ // Toggle row: checkbox + display name + description. Unlike
+ // the MCP tool rows there is no list rebuild behind these binds —
+ // the flags are plain service booleans — so the crossfade and
+ // bounce can drive straight off `checked`.
+ component NativeToolRow : Rectangle {
+ id: toolRow
+
+ property string label: ""
+ property string description: ""
+ property bool checked: false
+ property bool rowEnabled: true
+ signal toggled()
+
+ height: 56
+ radius: 8
+ color: toolRow.checked
+ ? Theme.withAlpha(Theme.primary, 0.14)
+ : Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
+ opacity: toolRow.rowEnabled ? 1 : 0.45
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ Behavior on color { ColorAnimation { duration: 220 } }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
+ opacity: toolHover.hovered && toolRow.rowEnabled ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ HoverHandler {
+ id: toolHover
+ enabled: toolRow.rowEnabled
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ SequentialAnimation {
+ id: checkBounce
+ NumberAnimation {
+ target: checkBox; property: "scale"
+ to: 1.15; duration: 140; easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ target: checkBox; property: "scale"
+ to: 1.0; duration: 240; easing.type: Easing.InOutQuad
+ }
+ }
+
+ Item {
+ id: checkBox
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ width: 20
+ height: 20
+
+ DankIcon {
+ anchors.fill: parent
+ name: "check_box_outline_blank"
+ size: 20
+ visible: opacity > 0.01
+ opacity: toolRow.checked ? 0 : 1
+ color: Theme.surfaceVariantText
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 220
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+
+ DankIcon {
+ anchors.fill: parent
+ name: "check_box"
+ size: 20
+ visible: opacity > 0.01
+ opacity: toolRow.checked ? 1 : 0
+ color: Theme.primary
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 220
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ anchors.left: checkBox.right
+ anchors.leftMargin: 12
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+
+ Text {
+ Layout.fillWidth: true
+ text: toolRow.label
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: toolRow.description
+ color: Theme.surfaceVariantText
+ font.pixelSize: 11
+ wrapMode: Text.Wrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ }
+ }
+
+ TapHandler {
+ id: tapPoint
+ enabled: toolRow.rowEnabled
+ onTapped: {
+ checkBounce.restart();
+ toolRow.toggled();
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: body
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: 10
+
+ // ── Section header ────────────────────────────────────────
+ RowLayout {
+ id: headerRow
+ Layout.fillWidth: true
+
+ Text {
+ text: "Native Tools"
+ color: Theme.primary
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ Item { Layout.fillWidth: true }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: !root.masterOn
+ text: "Native tools are switched off — use the language icon " +
+ "above the chat input to turn them on."
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ }
+
+ NativeToolRow {
+ Layout.fillWidth: true
+ label: "Web Search"
+ description: "Let the model search the web with DuckDuckGo."
+ checked: root.service ? root.service.webSearchEnabled : false
+ rowEnabled: root.masterOn
+ onToggled: if (root.service)
+ root.service.setNativeToolEnabled("web_search",
+ !root.service.webSearchEnabled)
+ }
+
+ NativeToolRow {
+ Layout.fillWidth: true
+ label: "Web Fetch"
+ description: "Let the model open a URL and read its content."
+ checked: root.service ? root.service.webFetchEnabled : false
+ rowEnabled: root.masterOn
+ onToggled: if (root.service)
+ root.service.setNativeToolEnabled("webfetch",
+ !root.service.webFetchEnabled)
+ }
+ }
+}
diff --git a/src/components/NexusChat.qml b/src/components/NexusChat.qml
new file mode 100644
index 0000000..b7f0ae9
--- /dev/null
+++ b/src/components/NexusChat.qml
@@ -0,0 +1,416 @@
+import QtQuick
+import QtQuick.Layouts
+import qs.Common
+import qs.Widgets
+
+Item {
+ id: chat
+
+ property var service: null
+ property var panel: null
+
+ state: "chat"
+ states: [
+ State { name: "chat" },
+ State { name: "settings" }
+ ]
+
+ // Called by NexusPanel when the panel finishes sliding in.
+ function focusInput() {
+ if (state === "chat" && composer.focusInput)
+ composer.focusInput();
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 8
+
+ // Chat box: same matugen hue as the panel, just more
+ // opaque — reads darker/more solid without graying out.
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Theme.cornerRadius
+ color: Theme.withAlpha(Theme.surfaceContainer, 0.9)
+ clip: true
+
+ MessageList {
+ id: messageList
+ anchors.fill: parent
+ anchors.margins: 6
+ service: chat.service
+ }
+
+ // Centered plugin logo for the empty conversation.
+ HexSphereLogo {
+ anchors.centerIn: parent
+ radius: 66
+ // Same opacity-driven visible pattern as the chip
+ // below: the empty-state fade-out must get to play.
+ opacity: (chat.service ? chat.service.messageCount === 0 : true) ? 1 : 0
+ visible: opacity > 0.01
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ // Status chips, bottom-left of the chat box just above the
+ // composer gap: MCP toolset count and the native web tools
+ // indicator. Both only show on an empty conversation (they
+ // fade away once the chat starts).
+ Row {
+ id: statusChips
+ anchors.left: parent.left
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: 10
+ anchors.bottomMargin: 8
+ spacing: 8
+
+ // Active-toolset chip: counts MCP servers currently
+ // contributing tools; only shows when that's > 0.
+ Rectangle {
+ id: toolsetChip
+ readonly property int count: chat.service
+ ? chat.service.activeToolsetCount() : 0
+ // Freezes at the last non-zero count so the label
+ // doesn't flip to "0 Active Toolsets" mid fade-out.
+ readonly property int displayCount: count > 0 ? count : lastNonZero
+ property int lastNonZero: 0
+ onCountChanged: if (count > 0) lastNonZero = count
+ readonly property bool chatEmpty: chat.service
+ ? chat.service.messageCount === 0 : true
+ readonly property bool shown: count > 0 && chatEmpty
+ width: chipRow.implicitWidth + 20
+ height: 28
+ radius: 8
+ // visible derives from opacity (never from `shown`):
+ // flipping visible directly would hide the item before
+ // the fade-out animation could play.
+ opacity: shown ? 1 : 0
+ visible: opacity > 0.01
+ Behavior on opacity { NumberAnimation { duration: 220 } }
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.75)
+ border.width: 1
+ border.color: Theme.withAlpha(Theme.outlineVariant, 0.7)
+
+ Row {
+ id: chipRow
+ anchors.centerIn: parent
+ spacing: 6
+
+ DankIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ name: "construction"
+ size: 14
+ color: Theme.primary
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: toolsetChip.displayCount === 1
+ ? "1 Active Toolset"
+ : toolsetChip.displayCount + " Active Toolsets"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ }
+ }
+ }
+
+ // Web search chip: independent of the MCP count — shows
+ // while the native tools master switch is on (and at
+ // least one native tool is enabled).
+ Rectangle {
+ id: webSearchChip
+ readonly property bool nativeOn: chat.service
+ ? chat.service.webToolsEnabled
+ && (chat.service.webSearchEnabled
+ || chat.service.webFetchEnabled)
+ : false
+ readonly property bool chatEmpty: chat.service
+ ? chat.service.messageCount === 0 : true
+ readonly property bool shown: nativeOn && chatEmpty
+ width: webChipRow.implicitWidth + 20
+ height: 28
+ radius: 8
+ opacity: shown ? 1 : 0
+ visible: opacity > 0.01
+ Behavior on opacity { NumberAnimation { duration: 220 } }
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.75)
+ border.width: 1
+ border.color: Theme.withAlpha(Theme.outlineVariant, 0.7)
+
+ Row {
+ id: webChipRow
+ anchors.centerIn: parent
+ spacing: 6
+
+ DankIcon {
+ anchors.verticalCenter: parent.verticalCenter
+ name: "language"
+ size: 14
+ color: Theme.primary
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Web Search Enabled"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ }
+ }
+ }
+ }
+ }
+
+ ChatComposer {
+ id: composer
+ Layout.fillWidth: true
+ service: chat.service
+ placeholder: chat.service ? chat.service.placeholder : "Ask anything…"
+ onSubmitted: (text, images) => {
+ // While a user message is being edited the composer
+ // submit performs the edit (drops everything after
+ // that message, regenerates); otherwise a normal send.
+ if (chat.service && chat.service.editingMessageId.length > 0)
+ chat.service.editUserMessage(chat.service.editingMessageId, text);
+ else
+ chat.service.sendMessage(text, images);
+ }
+ onEscapePressed: {
+ if (chat.state === "settings")
+ chat.state = "chat";
+ else if (chat.service && chat.service.editingMessageId.length > 0)
+ chat.service.cancelEditUserMessage();
+ else if (chat.panel)
+ chat.panel.hide();
+ }
+ onNewChatRequested: {
+ if (chat.service) {
+ chat.service.clearChat();
+ chat.state = "chat";
+ }
+ }
+
+ Connections {
+ target: chat.service
+ // Edit requested from a message bubble: the composer
+ // receives the message text and takes focus.
+ function onEditRequested(text) {
+ composer.setText(text);
+ composer.focusInput();
+ }
+ }
+ }
+
+ // Bottom bar: provider chip, model chip, MCP toggle, then
+ // optional-params popup bottom-left; context meter pinned to
+ // the far right as an overlay. The RowLayout is anchored
+ // inside a wrapper so the meter never participates in the
+ // row's minimum width — in a narrow panel the row may
+ // overflow (pre-existing), but the meter stays visible at
+ // the right edge.
+ Item {
+ id: bottomBarWrap
+ Layout.fillWidth: true
+ implicitHeight: bottomBar.implicitHeight
+
+ RowLayout {
+ id: bottomBar
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ ModelSelector {
+ service: chat.service
+ }
+
+ // Image attach chip: opens the file picker (clipboard paste
+ // in the composer is the other ingest path). Same chip
+ // shape and hover treatment as the MCP toggle.
+ Rectangle {
+ id: attachChip
+ Layout.alignment: Qt.AlignVCenter
+ implicitWidth: 34
+ implicitHeight: 34
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
+ opacity: attachHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "attach_file"
+ size: 18
+ color: attachHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 250 } }
+ }
+
+ HoverHandler {
+ id: attachHover
+ cursorShape: Qt.PointingHandCursor
+ onHoveredChanged: attachTip.hovering = hovered
+ }
+
+ TapHandler {
+ onTapped: {
+ attachTip.close();
+ composer.attachFromFile();
+ }
+ }
+ }
+
+ // MCP tools master switch: construction icon, primary when
+ // tools are on. Same chip shape and hover treatment as the
+ // model selector's zones (overlay fade + pointing cursor).
+ Rectangle {
+ id: mcpChip
+ Layout.alignment: Qt.AlignVCenter
+ implicitWidth: 34
+ implicitHeight: 34
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
+
+ readonly property bool on: chat.service
+ ? chat.service.mcpToolsEnabled : false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
+ opacity: mcpHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "construction"
+ size: 18
+ filled: mcpChip.on
+ color: mcpChip.on || mcpHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 250 } }
+ }
+
+ HoverHandler {
+ id: mcpHover
+ cursorShape: Qt.PointingHandCursor
+ onHoveredChanged: mcpTip.hovering = hovered
+ }
+
+ TapHandler {
+ onTapped: {
+ mcpTip.close();
+ if (chat.service)
+ chat.service.setMcpToolsEnabled(
+ !chat.service.mcpToolsEnabled);
+ }
+ }
+ }
+
+ // Native web tools switch (web_search / webfetch): language
+ // icon, primary when on. Same chip recipe as the MCP toggle.
+ Rectangle {
+ id: webChip
+ Layout.alignment: Qt.AlignVCenter
+ implicitWidth: 34
+ implicitHeight: 34
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5)
+
+ readonly property bool on: chat.service
+ ? chat.service.webToolsEnabled : false
+
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
+ opacity: webHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "language"
+ size: 18
+ filled: webChip.on
+ color: webChip.on || webHover.hovered
+ ? Theme.primary : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 250 } }
+ }
+
+ HoverHandler {
+ id: webHover
+ cursorShape: Qt.PointingHandCursor
+ onHoveredChanged: webTip.hovering = hovered
+ }
+
+ TapHandler {
+ onTapped: {
+ webTip.close();
+ if (chat.service)
+ chat.service.setWebToolsEnabled(
+ !chat.service.webToolsEnabled);
+ }
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+ }
+
+ // Context usage meter: ring fills with conversation
+ // progress; hover opens an opaque token-count pill that
+ // overlays the chips to its left (constant layout
+ // footprint — narrow panels never re-flow). Pinned to the
+ // bar's right edge OUTSIDE the RowLayout so its width is
+ // never part of the row's minimum; z keeps it above.
+ ContextMeter {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ service: chat.service
+ z: 2
+ }
+ }
+ }
+
+ // Hover tooltips for the composer-bar chips: comic-bubble popups
+ // that open above their anchor after a 1s dwell and close when
+ // the pointer leaves (or the chip is pressed).
+ BubbleTip {
+ id: attachTip
+ parent: attachChip
+ tipText: "Attachments"
+ }
+
+ BubbleTip {
+ id: mcpTip
+ parent: mcpChip
+ tipText: "MCP Toolsets"
+ }
+
+ BubbleTip {
+ id: webTip
+ parent: webChip
+ tipText: "Enable Web Search"
+ }
+
+ // Settings view: fills the panel, on top of the chat, opened from
+ // the panel bar's tune button (state: "settings"). Fades in/out on
+ // state changes; visible tracks opacity so the view drops out of
+ // interaction/rendering once the fade-out lands.
+ NexusSettings {
+ id: settingsView
+ anchors.fill: parent
+ opacity: chat.state === "settings" ? 1 : 0
+ visible: opacity > 0.01
+ Behavior on opacity {
+ NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
+ }
+ service: chat.service
+ }
+}
diff --git a/src/components/NexusPanel.qml b/src/components/NexusPanel.qml
new file mode 100644
index 0000000..e0d7b27
--- /dev/null
+++ b/src/components/NexusPanel.qml
@@ -0,0 +1,252 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+pragma ComponentBehavior: Bound
+
+// Slideout panel: transparent full-height window; the visible surface
+// slides in from its screen edge and the mask confines input to it.
+PanelWindow {
+ id: root
+
+ property bool isVisible: false
+ property var modelData: null
+ property real panelWidth: 480
+ property bool expandable: true
+ property bool expanded: false
+ property real expandedWidth: 960
+ property Component content: null
+ property real gap: 6
+ property bool panelOnLeft: false
+ property var service: null
+
+ function show() {
+ visible = true;
+ isVisible = true;
+ }
+
+ function hide() {
+ isVisible = false;
+ }
+
+ function toggle() {
+ if (isVisible) hide();
+ else show();
+ }
+
+ // Emitted after the slide-in animation finishes.
+ signal opened()
+
+ onOpened: {
+ if (contentLoader.item && typeof contentLoader.item.focusInput === "function")
+ contentLoader.item.focusInput();
+ }
+
+ visible: isVisible
+ screen: modelData
+ color: "transparent"
+
+ anchors.top: true
+ anchors.bottom: true
+ anchors.right: !panelOnLeft
+ anchors.left: panelOnLeft
+
+ readonly property real activeWidth: expandable && expanded ? expandedWidth : panelWidth
+ implicitWidth: expandable ? expandedWidth + gap : panelWidth + gap
+ implicitHeight: modelData ? modelData.height : 800
+
+ WlrLayershell.namespace: "nexus:panel"
+ WlrLayershell.layer: WlrLayershell.Top
+ WlrLayershell.exclusiveZone: 0
+ WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
+
+ readonly property real dpr: CompositorService.getScreenScale(root.screen)
+ readonly property real alignedWidth: Theme.px(activeWidth + gap, dpr)
+
+ mask: Region {
+ item: Rectangle {
+ x: root.panelOnLeft ? 0 : root.width - root.alignedWidth
+ y: 0
+ width: root.alignedWidth
+ height: root.height
+ }
+ }
+
+ Item {
+ id: slide
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ // Conditional side anchors don't reset to undefined on
+ // re-evaluation — the slide kept both and stretched. Use x.
+ x: root.panelOnLeft ? 0 : parent.width - width
+ width: root.alignedWidth
+
+ property real offset: root.panelOnLeft ? -alignedWidth : alignedWidth
+
+ Connections {
+ target: root
+ function onIsVisibleChanged() {
+ slide.offset = root.isVisible ? 0 : (root.panelOnLeft ? -slide.width : slide.width);
+ }
+
+ // offset's binding is dead after the imperative write
+ // above — re-derive on edge flips too, or the slide
+ // renders detached from the mask.
+ function onPanelOnLeftChanged() {
+ slide.offset = root.isVisible ? 0
+ : (root.panelOnLeft ? -slide.width : slide.width);
+ }
+ }
+
+ Behavior on offset {
+ NumberAnimation {
+ duration: 400
+ easing.type: Easing.OutCubic
+ onRunningChanged: {
+ if (!running && !root.isVisible) root.visible = false;
+ if (!running && root.isVisible) root.opened();
+ }
+ }
+ }
+
+ Behavior on width {
+ NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
+ }
+
+ Item {
+ id: layeredContent
+ layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true"
+ && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
+ layer.smooth: false
+ layer.textureSize: Qt.size(width * root.dpr, height * root.dpr)
+
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width
+ x: Theme.snap(slide.offset, root.dpr)
+
+ Item {
+ anchors.fill: parent
+ anchors.topMargin: root.gap
+ anchors.bottomMargin: root.gap
+ anchors.rightMargin: root.panelOnLeft ? 0 : root.gap
+ anchors.leftMargin: root.panelOnLeft ? root.gap : 0
+
+ // ── Rounded translucent panel: bar + content ───────
+ Rectangle {
+ id: panelSurface
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: Qt.rgba(
+ Theme.surfaceContainer.r,
+ Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b,
+ SettingsData.popupTransparency
+ )
+ border.width: 1
+ border.color: Theme.surfaceVariantAlpha
+
+ // Panel bar (title + expand + close), DankSlideout-style
+ Item {
+ id: panelBar
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: Theme.spacingL
+ height: 36
+
+ Text {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Nexus AI"
+ color: Theme.surfaceText
+ font.pixelSize: 16
+ font.weight: Font.Medium
+ }
+
+ Row {
+ id: barButtons
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingXS
+
+ DankActionButton {
+ iconName: "delete_sweep"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: if (root.service) root.service.clearChat()
+ }
+
+ DankActionButton {
+ iconName: contentLoader.item
+ && contentLoader.item.state === "settings"
+ ? "arrow_back" : "tune"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: {
+ if (!contentLoader.item) return;
+ contentLoader.item.state =
+ contentLoader.item.state === "settings" ? "chat" : "settings";
+ }
+ }
+
+ // Move the panel to the other screen edge;
+ // the arrow points where it will go.
+ DankActionButton {
+ iconName: root.service && root.service.panelEdge === "left"
+ ? "arrow_right" : "arrow_left"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: {
+ if (!root.service) return;
+ root.service.panelEdge =
+ root.service.panelEdge === "left" ? "right" : "left";
+ root.service.saveSetting("panelEdge", root.service.panelEdge);
+ }
+ }
+
+ DankActionButton {
+ id: expandButton
+ iconName: root.expanded ? "unfold_less" : "unfold_more"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ transform: Rotation {
+ angle: 90
+ origin.x: expandButton.width / 2
+ origin.y: expandButton.height / 2
+ }
+ onClicked: root.expanded = !root.expanded
+ }
+
+ DankActionButton {
+ iconName: "close"
+ iconSize: Theme.iconSize - 4
+ iconColor: Theme.surfaceText
+ onClicked: root.hide()
+ }
+ }
+ }
+
+ Item {
+ anchors.top: panelBar.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.bottomMargin: Theme.spacingL
+
+ Loader {
+ id: contentLoader
+ anchors.fill: parent
+ sourceComponent: root.content
+ }
+ }
+ }
+ }
+ }
+}
+}
diff --git a/src/components/NexusSettings.qml b/src/components/NexusSettings.qml
new file mode 100644
index 0000000..ffec20a
--- /dev/null
+++ b/src/components/NexusSettings.qml
@@ -0,0 +1,204 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Widgets
+import "../lib/PromptPresets.js" as PromptPresets
+
+Item {
+ id: settings
+
+ property var service: null
+
+ // Construction-time and service-load-time binding evaluation must not
+ // write back to settings. Component.onCompleted fires bottom-up (before
+ // NexusService._loadSettings runs), so the flag flips via Qt.callLater —
+ // after the whole creation batch and its synchronous binding updates.
+ property bool _loading: true
+ // Custom write-in mode: the box is editable and the Custom chip
+ // stays highlighted even while the prompt is still empty.
+ property bool customMode: false
+
+ Component.onCompleted: Qt.callLater(() => { settings._loading = false })
+
+ function persist(key, value) {
+ // property assignment happens at call site; persist immediately
+ if (_loading) return;
+ service.saveSetting(key, value);
+ }
+
+ // Opaque-to-panel backdrop: same color as the panel container, so
+ // the chat below does not bleed through the settings page.
+ Rectangle {
+ anchors.fill: parent
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g,
+ Theme.surfaceContainer.b, SettingsData.popupTransparency)
+ }
+
+ Flickable {
+ id: settingsScroll
+
+ anchors.fill: parent
+ contentWidth: width
+ contentHeight: column.implicitHeight + 32
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+
+ ColumnLayout {
+ id: column
+ width: settings.width - 32
+ x: 16
+ spacing: 16
+
+ // ── Header row ────────────────────────────────────────────
+ RowLayout {
+ Layout.fillWidth: true
+ Text {
+ text: "Settings"
+ color: Theme.surfaceText
+ font.pixelSize: 18
+ font.bold: true
+ }
+ }
+
+ // ── Providers (multi-provider instances) ──────────────────
+ ProvidersSection {
+ Layout.fillWidth: true
+ service: settings.service
+ }
+
+ // ── Native tools card (parked) ────────────────────────────
+ // NativeToolsSection {
+ // Layout.fillWidth: true
+ // service: settings.service
+ // }
+
+ // ── MCP card ──────────────────────────────────────────────
+ MCPSection {
+ Layout.fillWidth: true
+ service: settings.service
+ }
+
+ // ── Prompt card ───────────────────────────────────────────
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 10
+
+ Text {
+ text: "System Prompt"
+ color: Theme.primary
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ // Personality presets; "Custom" selects the write-in
+ // box (highlight follows the box content).
+ Row {
+ Layout.fillWidth: true
+ spacing: 6
+
+ Repeater {
+ model: PromptPresets.PRESETS
+
+ delegate: Rectangle {
+ id: presetChip
+ required property var modelData
+ readonly property bool active:
+ settings.customMode
+ ? modelData.id === "custom"
+ : PromptPresets.detect(service ? service.systemPrompt : "")
+ === modelData.id
+
+ width: presetLabel.implicitWidth + 20
+ height: 26
+ radius: 13
+ color: presetChip.active
+ ? Theme.withAlpha(Theme.primary, 0.22)
+ : Theme.withAlpha(Theme.surfaceContainerHighest, 0.5)
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ Text {
+ id: presetLabel
+ anchors.centerIn: parent
+ text: presetChip.modelData.name
+ color: presetChip.active ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: 11
+ font.bold: presetChip.active
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (!service) return;
+ if (presetChip.modelData.id !== "custom") {
+ settings.customMode = false;
+ service.systemPrompt = presetChip.modelData.text;
+ } else {
+ // Entering custom mode: blank the
+ // preset text so the user can write
+ // their own, then focus the box.
+ settings.customMode = true;
+ if (PromptPresets.detect(service.systemPrompt)
+ !== "custom")
+ service.systemPrompt = "";
+ promptArea.forceActiveFocus();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ // Box hugs the pure text height: 13px visual padding
+ // above and below, exactly.
+ Layout.preferredHeight: Math.min(220, promptArea.contentHeight + 26)
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
+ border.width: promptArea.activeFocus ? 1 : 0
+ border.color: Theme.primary
+ clip: true
+
+ TextArea {
+ id: promptArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 13
+ topPadding: 0
+ bottomPadding: 0
+ leftPadding: 0
+ rightPadding: 0
+ // Preset text is fixed; only Custom is editable.
+ readOnly: !settings.customMode
+ && PromptPresets.detect(service ? service.systemPrompt : "")
+ !== "custom"
+ text: service ? service.systemPrompt : ""
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ wrapMode: TextArea.Wrap
+ placeholderText: "You are a concise assistant…"
+ placeholderTextColor: Theme.surfaceVariantText
+ background: null
+ selectionColor: Theme.withAlpha(Theme.primary, 0.4)
+ selectedTextColor: Theme.surfaceText
+ onTextChanged: if (service && !settings._loading) {
+ service.systemPrompt = text;
+ settings.persist("systemPrompt", text);
+ }
+ }
+ }
+ }
+
+ // ── Panel card ────────────────────────────────────────────
+ // (Panel side toggle moved to the panel top bar.)
+
+ Item { Layout.fillHeight: true }
+ }
+
+ ScrollBar.vertical: DankScrollbar {}
+ }
+}
diff --git a/src/components/ProvidersSection.qml b/src/components/ProvidersSection.qml
new file mode 100644
index 0000000..b701f32
--- /dev/null
+++ b/src/components/ProvidersSection.qml
@@ -0,0 +1,993 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import qs.Common
+import qs.Widgets
+import "../lib/Providers.js" as Providers
+import "../lib/ModelIcons.js" as ModelIcons
+import "../lib/FuzzyMatch.js" as FuzzyMatch
+
+// Provider settings: one collapsible row per instance with an editor
+// card (name, base URL, key, models grid). Keys persist in the
+// keyring when secret-tool is available, else memory-only.
+Item {
+ id: root
+
+ property var service: null
+ // Which instance is expanded — only one at a time.
+ property string expandedId: ""
+ // Expanded-card grid state, hoisted so it survives the delegate
+ // rebuilds every service write triggers.
+ property string modelFilterText: ""
+ property real modelGridY: 0
+ property bool _gridRebuilding: false
+ function markGridRebuild() {
+ _gridRebuilding = true;
+ gridSettleTimer.restart();
+ }
+ Timer {
+ id: gridSettleTimer
+ interval: 0
+ onTriggered: {
+ root._gridRebuilding = false;
+ modelFlick.contentY = Math.min(root.modelGridY,
+ Math.max(0, modelFlick.contentHeight - modelFlick.height));
+ }
+ }
+ // One-shot animation handoff: a click arms a key so the delegate
+ // recreated by the service write can replay the state change (it
+ // is born at the new value; replaying from the old one is the
+ // only way a rebuilt row can animate).
+ property string animKey: ""
+ property bool animFrom: false
+
+ implicitHeight: body.implicitHeight
+
+ // Add-provider row: brand icon + registry display name + subtle
+ // wire-format tag.
+ component AddRow : Rectangle {
+ id: addRow
+
+ property string title: ""
+ property string tag: ""
+ property string iconStem: ""
+ signal chosen()
+
+ width: parent ? parent.width : 280
+ height: 48
+ radius: 6
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.22)
+
+ // Hover state layer (model-selector chip pattern): fixed tint,
+ // opacity fade. MouseArea instead of HoverHandler — the
+ // reliable hover source inside a Popup.
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.45)
+ opacity: addHover.containsMouse ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ MouseArea {
+ id: addHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: addRow.chosen()
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 10
+ spacing: 10
+
+ BrandIcon {
+ Layout.alignment: Qt.AlignVCenter
+ size: 20
+ stem: addRow.iconStem
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: addRow.title
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ }
+
+ Text {
+ text: addRow.tag
+ color: Theme.surfaceVariantText
+ font.pixelSize: 11
+ }
+ }
+ }
+
+ // Compact switch (enable/disable provider). Plain Rectangle +
+ // MouseArea so the press is consumed here and never also toggles
+ // the row's expand/collapse TapHandler.
+ component MiniToggle : Rectangle {
+ id: mini
+
+ property bool checked: true
+ signal toggled()
+
+ width: 34
+ height: 20
+ radius: 10
+ color: mini.shown ? Theme.primary
+ : Theme.withAlpha(Theme.outlineVariant, 0.5)
+ Behavior on color { ColorAnimation { duration: 220 } }
+
+ Rectangle {
+ x: mini.shown ? mini.width - width - 2 : 2
+ anchors.verticalCenter: parent.verticalCenter
+ width: 16
+ height: 16
+ radius: 8
+ color: mini.shown ? Theme.surfaceContainerHighest
+ : Theme.surfaceVariantText
+ Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
+ Behavior on color { ColorAnimation { duration: 220 } }
+ }
+
+ // Armed creation replay: the click that toggles this provider
+ // rebuilds the row, so the fresh toggle starts from the
+ // pre-click state and rebinds 50ms later — the knob slide and
+ // color fade play on the new row.
+ property bool shown: checked
+
+ function armFrom(state) {
+ shown = state;
+ armTimer.restart();
+ }
+
+ Timer {
+ id: armTimer
+ interval: 50
+ onTriggered: mini.shown = Qt.binding(function() { return mini.checked; })
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: mini.toggled()
+ }
+ }
+
+ // Small neutral icon button (no accent): used for destructive and
+ // additive row actions.
+ component NeutralIconButton : Rectangle {
+ id: nib
+
+ property string iconName: ""
+ property int buttonHeight: 20
+ property int iconSize: 14
+ // Accent mode: primary fill + strong icon (e.g. the custom
+ // model "+" once the field has text).
+ property bool accent: false
+ signal clicked()
+
+ width: buttonHeight + 10
+ height: buttonHeight
+ radius: 6
+ color: nib.accent ? (nibHover.hovered ? Theme.primary
+ : Theme.withAlpha(Theme.primary, 0.85))
+ : (nibHover.hovered ? Theme.withAlpha(Theme.surfaceContainerHighest, 0.9)
+ : Theme.withAlpha(Theme.surfaceContainerHighest, 0.5))
+ Behavior on color { ColorAnimation { duration: 200 } }
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: nib.iconName
+ size: nib.iconSize
+ color: nib.accent ? Theme.surfaceText : Theme.surfaceVariantText
+ }
+
+ HoverHandler { id: nibHover; cursorShape: Qt.PointingHandCursor }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: nib.clicked()
+ }
+ }
+
+ // Model cell: checkbox icon + brand icon + display label, with a
+ // pencil to rename the model visually (the logical id stays
+ // untouched — display labels live on the provider instance).
+ component ModelCheckRow : Rectangle {
+ id: modelRow
+
+ property string label: ""
+ property string iconStem: ""
+ property bool checked: false
+ property string providerId: ""
+ property string displayLabel: label
+ property bool editing: false
+ property bool removable: false
+ signal removed()
+ // 1.0 at rest; entrance replay bounces it.
+ property real checkScale: 1.0
+ // Armed creation replay state (see MiniToggle.shown).
+ property bool shownChecked: checked
+ signal toggled()
+ signal renamed(string text)
+
+ function armFrom(state) {
+ shownChecked = state;
+ armTimer.restart();
+ }
+
+ function commitRename() {
+ if (!editing) return;
+ editing = false;
+ renamed(editInput.text);
+ }
+
+ Timer {
+ id: armTimer
+ interval: 50
+ onTriggered: {
+ modelRow.shownChecked = Qt.binding(function() { return modelRow.checked; });
+ checkBounce.restart();
+ }
+ }
+
+ height: 56
+ radius: 8
+ color: modelRow.checked
+ ? Theme.withAlpha(Theme.primary, 0.14)
+ : Theme.withAlpha(Theme.surfaceContainerHigh, 0.4)
+
+ // Hover state layer, same as the chip: tint + opacity fade.
+ Rectangle {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.6)
+ opacity: modelHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ HoverHandler { id: modelHover; cursorShape: Qt.PointingHandCursor }
+
+ SequentialAnimation {
+ id: checkBounce
+ NumberAnimation {
+ target: modelRow; property: "checkScale"
+ to: 1.15; duration: 140; easing.type: Easing.InOutQuad
+ }
+ NumberAnimation {
+ target: modelRow; property: "checkScale"
+ to: 1.0; duration: 240; easing.type: Easing.InOutQuad
+ }
+ }
+
+ // Checkbox: crossfades between the empty and checked boxes so
+ // the tick itself animates (the icon swap alone is instant).
+ Item {
+ id: checkBox
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ width: 20
+ height: 20
+ scale: modelRow.checkScale
+
+ DankIcon {
+ anchors.fill: parent
+ name: "check_box_outline_blank"
+ size: 20
+ visible: opacity > 0.01
+ opacity: modelRow.shownChecked ? 0 : 1
+ color: modelRow.shownChecked ? Theme.primary : Theme.surfaceVariantText
+ Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
+ }
+
+ DankIcon {
+ anchors.fill: parent
+ name: "check_box"
+ size: 20
+ visible: opacity > 0.01
+ opacity: modelRow.shownChecked ? 1 : 0
+ color: Theme.primary
+ Behavior on opacity { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } }
+ }
+ }
+
+ BrandIcon {
+ size: 22
+ anchors.left: checkBox.right
+ anchors.leftMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ stem: modelRow.iconStem
+ }
+
+ Text {
+ id: labelText
+ visible: !modelRow.editing
+ anchors.left: parent.left
+ anchors.leftMargin: 72
+ anchors.right: modelRow.removable ? removeZone.left : editZone.left
+ anchors.rightMargin: 4
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelRow.displayLabel
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ }
+
+ // Remove: custom models only.
+ Item {
+ id: removeZone
+ visible: modelRow.removable
+ anchors.right: editZone.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 26
+ height: parent.height
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: 14
+ color: removeZoneHover.hovered ? Theme.error : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ HoverHandler {
+ id: removeZoneHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: modelRow.removed()
+ }
+ }
+
+ // Inline rename editor: Enter commits, Escape cancels, focus
+ // loss commits. Typing the logical id clears the rename.
+ TextInput {
+ id: editInput
+ visible: modelRow.editing
+ anchors.left: parent.left
+ anchors.leftMargin: 72
+ anchors.right: editZone.left
+ anchors.rightMargin: 4
+ anchors.verticalCenter: parent.verticalCenter
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ clip: true
+ selectByMouse: true
+ onAccepted: modelRow.commitRename()
+ onActiveFocusChanged: if (!activeFocus) modelRow.commitRename()
+ Keys.onEscapePressed: modelRow.editing = false
+ }
+
+ // Pencil: opens the inline rename editor.
+ Item {
+ id: editZone
+ width: 26
+ anchors.right: parent.right
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ height: parent.height
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "edit"
+ size: 14
+ color: editZoneHover.hovered ? Theme.primary
+ : Theme.surfaceVariantText
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ HoverHandler {
+ id: editZoneHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (modelRow.editing) return;
+ modelRow.editing = true;
+ editInput.text = modelRow.displayLabel;
+ editInput.forceActiveFocus();
+ editInput.cursorPosition = editInput.text.length;
+ }
+ }
+ }
+
+ TapHandler {
+ id: tapPoint
+ onTapped: {
+ // Taps over the action zones rename/remove, not toggle.
+ var exclude = 32 + (modelRow.removable ? 26 : 0);
+ if (tapPoint.point.position.x > modelRow.width - exclude) return;
+ checkBounce.restart();
+ modelRow.toggled();
+ }
+ }
+
+ }
+
+ ColumnLayout {
+ id: body
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: 10
+
+ // ── Section header ────────────────────────────────────────
+ RowLayout {
+ id: headerRow
+ Layout.fillWidth: true
+
+ Text {
+ text: "Providers"
+ color: Theme.primary
+ font.pixelSize: 13
+ font.bold: true
+ }
+
+ Item { Layout.fillWidth: true }
+
+ DankButton {
+ text: "Add provider"
+ iconName: "add"
+ buttonHeight: 36
+ onClicked: addPicker.visible ? addPicker.close() : addPicker.open()
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: !root.service || root.service.providers.length === 0
+ text: "No providers yet — add one to start."
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ }
+
+ // ── Instance list ─────────────────────────────────────────
+ // Array model: the service's reassignment pattern rebuilds the
+ // rows on every write. State-change animations replay on the
+ // fresh row via the animKey/animFrom handoff (see MiniToggle
+ // and ModelCheckRow armFrom).
+ Repeater {
+ model: root.service ? root.service.providers : []
+
+ delegate: ColumnLayout {
+ id: block
+
+ required property var modelData
+
+ readonly property string pid: modelData && modelData.id ? String(modelData.id) : ""
+ readonly property var reg:
+ modelData && Providers.REGISTRY[modelData.type] ? Providers.REGISTRY[modelData.type] : null
+ readonly property bool needsKey: reg ? !!reg.needsKey : false
+ readonly property string keyStatus:
+ root.service && pid.length > 0 ? root.service.keyStatus(pid) : "none"
+ readonly property bool missingKey: keyStatus === "none" && needsKey
+ readonly property bool expanded: root.expandedId === pid
+ readonly property string envVarName:
+ reg && reg.envVar ? String(reg.envVar)
+ : (modelData && modelData.type === "custom" && modelData.name
+ ? Providers.customEnvVar(String(modelData.name)) : "")
+ readonly property string keyStatusLine:
+ keyStatus === "keyring" ? "Stored in system keyring"
+ : keyStatus === "session" ? "Key set for this session"
+ : keyStatus.indexOf("env:") === 0 ? "Using env var " + keyStatus.substring(4)
+ : missingKey ? "No key — set one or export " + (envVarName.length > 0 ? envVarName : "an env var")
+ : "No key required"
+ readonly property string modelsErrorText: {
+ if (!root.service || !root.service.modelsErrors || pid.length === 0) return "";
+ var m = root.service.modelsErrors[pid];
+ return m ? String(m) : "";
+ }
+ readonly property string keyMessageText:
+ root.service && root.service.keyMessages && pid.length > 0
+ ? (root.service.keyMessages[pid] ? String(root.service.keyMessages[pid]) : "")
+ : ""
+ // Provider brand logo (providerIcon resolves the type,
+ // e.g. google → the Gemini spark in icons/models/).
+ // Provider logo stem — BrandIcon resolves the theme
+ // folder (icons/dark|light), e.g. claude → anthropic.
+ readonly property string iconStem:
+ modelData && modelData.type
+ ? ModelIcons.providerIcon(String(modelData.type)) : ""
+
+ Layout.fillWidth: true
+ spacing: 4
+
+ // Collapsed row: chevron + brand icon + name, a
+ // right-side enable toggle, (+ missing-key dot).
+ // Disabled providers dim their identity.
+ Rectangle {
+ id: providerRow
+
+ Layout.fillWidth: true
+ implicitHeight: 72
+ radius: 6
+ // Expanded tint swaps the base (restart-free: it
+ // only changes on the expand click).
+ color: block.expanded
+ ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.3)
+ : Theme.withAlpha(Theme.surfaceContainerHigh, 0.22)
+ Behavior on color { ColorAnimation { duration: 250 } }
+
+ // Hover state layer (model-selector chip pattern):
+ // fixed tint, opacity fade.
+ Rectangle {
+ anchors.fill: parent
+ radius: 6
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.3)
+ opacity: rowHover.hovered ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 250 } }
+ }
+
+ readonly property bool off:
+ block.modelData && block.modelData.enabled === false
+
+ HoverHandler {
+ id: rowHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 10
+ spacing: 8
+
+ // Chevron: right when collapsed, rotates down
+ // when expanded (matches the card opening
+ // below the row).
+ DankIcon {
+ name: "expand_more"
+ size: 22
+ color: Theme.surfaceVariantText
+ rotation: block.expanded ? 0 : -90
+ opacity: providerRow.off ? 0.45 : 1
+ Behavior on rotation {
+ RotationAnimation {
+ duration: 200
+ easing.type: Easing.OutCubic
+ }
+ }
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ }
+
+ // Brand icon (theme folders via BrandIcon);
+ // hidden when no file exists for this type.
+ BrandIcon {
+ size: 24
+ stem: block.iconStem
+ Layout.alignment: Qt.AlignVCenter
+ opacity: providerRow.off ? 0.45 : 1
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
+ color: Theme.surfaceText
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ opacity: providerRow.off ? 0.45 : 1
+ Behavior on opacity { NumberAnimation { duration: 200 } }
+ }
+
+ MiniToggle {
+ Layout.alignment: Qt.AlignVCenter
+ checked: block.modelData
+ ? block.modelData.enabled !== false : true
+ onToggled: {
+ if (!root.service || block.pid.length === 0) return;
+ root.animKey = "p:" + block.pid;
+ root.animFrom = block.modelData
+ ? block.modelData.enabled !== false : true;
+ root.service.setProviderEnabled(block.pid, !root.animFrom);
+ }
+ Component.onCompleted: {
+ if (root.animKey === "p:" + block.pid) {
+ armFrom(root.animFrom);
+ root.animKey = "";
+ }
+ }
+ }
+
+ NeutralIconButton {
+ iconName: "delete"
+ Layout.alignment: Qt.AlignVCenter
+ onClicked: {
+ if (root.expandedId === block.pid) root.expandedId = "";
+ if (root.service && block.pid.length > 0)
+ root.service.removeProvider(block.pid);
+ }
+ }
+
+ Rectangle {
+ visible: block.missingKey
+ width: 8
+ height: 8
+ radius: 4
+ color: Theme.error
+ }
+ }
+
+ TapHandler {
+ onTapped: root.expandedId = block.expanded ? "" : block.pid
+ }
+ }
+
+ // Expanded editor card. The height/opacity animate so
+ // the card slides open instead of popping in; content
+ // clips during the transition.
+ Rectangle {
+ id: editorCard
+ Layout.fillWidth: true
+ readonly property int fullHeight: cardBody.implicitHeight + 24
+ property real animHeight: block.expanded ? fullHeight : 0
+ Behavior on animHeight {
+ NumberAnimation {
+ duration: 220
+ easing.type: Easing.OutCubic
+ }
+ }
+ Layout.preferredHeight: animHeight
+ visible: animHeight > 0.5
+ clip: true
+ opacity: block.expanded ? 1 : 0
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainerHighest, 0.35)
+
+ ColumnLayout {
+ id: cardBody
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 10
+
+ // Persist on editingFinished, not per keystroke:
+ // every service write resets this Repeater and
+ // would kill the focused field.
+ DankTextField {
+ Layout.fillWidth: true
+ labelText: "Name"
+ text: block.modelData && block.modelData.name ? String(block.modelData.name) : ""
+ onEditingFinished: if (root.service && block.pid.length > 0
+ && text !== (block.modelData && block.modelData.name ? String(block.modelData.name) : ""))
+ root.service.renameProvider(block.pid, text)
+ }
+
+ DankTextField {
+ Layout.fillWidth: true
+ labelText: "Base URL"
+ placeholderText: "https://api.openai.com/v1"
+ text: block.modelData && block.modelData.baseUrl ? String(block.modelData.baseUrl) : ""
+ onEditingFinished: if (root.service && block.pid.length > 0
+ && text.trim() !== (block.modelData && block.modelData.baseUrl ? String(block.modelData.baseUrl) : ""))
+ root.service.setProviderUrl(block.pid, text)
+ }
+
+ DankTextField {
+ Layout.fillWidth: true
+ labelText: "API key"
+ placeholderText: block.envVarName
+ text: root.service && root.service.sessionKeys && block.pid.length > 0
+ ? (root.service.sessionKeys[block.pid] || "") : ""
+ echoMode: passwordVisible ? TextInput.Normal : TextInput.Password
+ showPasswordToggle: true
+ onEditingFinished: if (root.service && block.pid.length > 0
+ && text.length > 0
+ && text !== (root.service.sessionKeys && root.service.sessionKeys[block.pid] ? String(root.service.sessionKeys[block.pid]) : "")) {
+ root.service.setInstanceKey(block.pid, text);
+ root.service.refreshModels(block.pid);
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: block.keyMessageText.length > 0
+ text: block.keyMessageText
+ color: Theme.error
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: block.needsKey && root.service
+ && !root.service.keyringAvailable
+ text: "secret-tool not found — keys last this session only; use env vars for persistence"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ DankButton {
+ text: root.service && root.service.modelsRefreshing
+ ? "Refreshing…" : "Refresh models"
+ iconName: "refresh"
+ buttonHeight: 36
+ enabled: root.service && !root.service.modelsRefreshing
+ onClicked: if (root.service && block.pid.length > 0)
+ root.service.refreshModels(block.pid)
+ }
+
+ Item { Layout.fillWidth: true }
+ }
+
+ Text {
+ text: "Models"
+ color: Theme.primary
+ font.pixelSize: 12
+ font.bold: true
+ }
+
+ // Manually register a model id the discovery
+ // endpoint doesn't list; auto-checked so it's
+ // immediately usable.
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ DankTextField {
+ id: customModelField
+ Layout.fillWidth: true
+ placeholderText: "Add custom model"
+ }
+
+ NeutralIconButton {
+ iconName: "add"
+ buttonHeight: 36
+ iconSize: 18
+ accent: customModelField.text.trim().length > 0
+ onClicked: {
+ var name = customModelField.text.trim();
+ if (!root.service || block.pid.length === 0 || !name)
+ return;
+ root.service.addCustomModel(block.pid, name);
+ customModelField.text = "";
+ }
+ }
+ }
+
+ DankTextField {
+ id: modelFilterField
+ Layout.fillWidth: true
+ placeholderText: "Search models"
+ showClearButton: true
+ text: root.modelFilterText
+ onTextChanged: root.modelFilterText = text
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: block.modelsErrorText.length > 0
+ text: block.modelsErrorText
+ color: Theme.error
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ }
+
+ Text {
+ Layout.fillWidth: true
+ visible: (!block.modelData || !block.modelData.discovered
+ || block.modelData.discovered.length === 0)
+ && (!block.modelData || !block.modelData.customModels
+ || block.modelData.customModels.length === 0)
+ text: "No models discovered — set URL/key, refresh, or add a custom model id"
+ color: Theme.surfaceVariantText
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ }
+
+ Item {
+ Layout.fillWidth: true
+ visible: block.modelData
+ && ((block.modelData.discovered && block.modelData.discovered.length > 0)
+ || (block.modelData.customModels && block.modelData.customModels.length > 0))
+ Layout.preferredHeight: 352
+
+ Flickable {
+ id: modelFlick
+ anchors.fill: parent
+ clip: true
+ contentHeight: modelGrid.implicitHeight
+ boundsBehavior: Flickable.StopAtBounds
+ // Remember scroll only outside rebuild
+ // cycles — delegate add/remove clamps
+ // contentY transiently.
+ onContentYChanged: {
+ if (root._gridRebuilding) return;
+ root.modelGridY = contentY;
+ }
+ // While the rebuild cycle is live, keep
+ // the view pinned to the stored position
+ // as rows stream back in.
+ onContentHeightChanged: if (root._gridRebuilding)
+ contentY = Math.min(root.modelGridY,
+ Math.max(0, contentHeight - modelFlick.height))
+
+ Grid {
+ id: modelGrid
+ width: parent.width
+ columns: 2
+ columnSpacing: Theme.spacingS
+ rowSpacing: Theme.spacingXS
+
+ Repeater {
+ onItemAdded: root.markGridRebuild()
+ onItemRemoved: root.markGridRebuild()
+ // Discovered ∪ custom models (deduped).
+ model: {
+ if (!block.modelData) return [];
+ var fl = root.modelFilterText.toLowerCase();
+ var out = [], seen = {};
+ var disc = block.modelData.discovered || [];
+ var cus = block.modelData.customModels || [];
+ for (var i = 0; i < disc.length; i++) {
+ if (!disc[i] || !disc[i].id || seen[String(disc[i].id)]) continue;
+ var did = String(disc[i].id);
+ var disp = root.service
+ ? root.service.modelLabel(block.pid, did) : did;
+ if (fl && !FuzzyMatch.matches(fl, did)
+ && !FuzzyMatch.matches(fl,
+ String(disp))) continue;
+ seen[did] = true;
+ out.push(disc[i]);
+ }
+ for (i = 0; i < cus.length; i++) {
+ var cid = String(cus[i] || "");
+ if (!cid || seen[cid]) continue;
+ var cdisp = root.service
+ ? root.service.modelLabel(block.pid, cid) : cid;
+ if (fl && !FuzzyMatch.matches(fl, cid)
+ && !FuzzyMatch.matches(fl,
+ String(cdisp))) continue;
+ seen[cid] = true;
+ out.push({ id: cid, custom: true });
+ }
+ return out;
+ }
+
+ delegate: ModelCheckRow {
+ required property var modelData
+
+ width: (cardBody.width - Theme.spacingS) / 2
+ label: modelData && modelData.id ? String(modelData.id) : ""
+ providerId: block.pid
+ displayLabel: root.service && modelData && modelData.id
+ ? root.service.modelLabel(block.pid, String(modelData.id))
+ : (modelData && modelData.id ? String(modelData.id) : "")
+ iconStem: {
+ // Unused read — dependency trick (see
+ // ModelSelector.activeStem): subscribes
+ // this binding to modelIconStems so it
+ // re-runs when the icon scan lands.
+ var _scan = root.service ? root.service.modelIconStems : null;
+ return modelData && modelData.id && block.modelData
+ ? ModelIcons.iconForModel(String(modelData.id),
+ String(block.modelData.type || ""))
+ : "";
+ }
+ checked: block.modelData && block.modelData.checkedModels
+ && modelData && modelData.id
+ ? block.modelData.checkedModels.indexOf(String(modelData.id)) >= 0
+ : false
+ onRenamed: (text) => {
+ if (!root.service || block.pid.length === 0
+ || !modelData || !modelData.id) return;
+ var clean = String(text || "").trim();
+ if (clean === String(modelData.id)) clean = "";
+ root.service.setModelLabel(block.pid, String(modelData.id), clean);
+ }
+ removable: modelData ? modelData.custom === true : false
+ onRemoved: {
+ if (!root.service || block.pid.length === 0
+ || !modelData || !modelData.id) return;
+ root.service.removeCustomModel(block.pid, String(modelData.id));
+ }
+ onToggled: {
+ if (!root.service || block.pid.length === 0
+ || !modelData || !modelData.id) return;
+ root.animKey = "m:" + block.pid + "/"
+ + String(modelData.id);
+ root.animFrom = block.modelData.checkedModels
+ ? block.modelData.checkedModels.indexOf(String(modelData.id)) >= 0
+ : false;
+ root.service.toggleModel(block.pid, String(modelData.id));
+ }
+ Component.onCompleted: {
+ var key = "m:" + block.pid + "/"
+ + (modelData && modelData.id ? String(modelData.id) : "");
+ if (root.animKey === key) {
+ armFrom(root.animFrom);
+ root.animKey = "";
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Scroll indicator: pill sized/positioned by
+ // the viewport ratio.
+ Item {
+ anchors.right: parent.right
+ anchors.rightMargin: 2
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 3
+ visible: modelFlick.contentHeight > modelFlick.height + 1
+
+ Rectangle {
+ radius: width / 2
+ width: parent.width
+ color: Theme.withAlpha(Theme.surfaceVariantText, 0.5)
+ readonly property real maxScroll: Math.max(
+ 1, modelFlick.contentHeight - modelFlick.height)
+ height: Math.max(24,
+ parent.height * (modelFlick.height
+ / Math.max(1, modelFlick.contentHeight)))
+ y: (modelFlick.contentY / maxScroll) * (parent.height - height)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ── Add-provider picker (below the header button) ─────────────
+ Popup {
+ id: addPicker
+ parent: root
+ y: headerRow.height + 4
+ x: Math.max(0, root.width - width)
+ width: 300
+ // The taller branded rows no longer fit the old 420 cap —
+ // grow the popup instead of scrolling it (a Flickable here
+ // broke row hover; rows must sit directly in contentItem).
+ height: Math.min(640, contentItem.implicitHeight + 16)
+ padding: 8
+
+ background: Rectangle {
+ radius: 8
+ color: Theme.withAlpha(Theme.surfaceContainer, 0.97)
+ border.color: Theme.surfaceVariantAlpha
+ border.width: 1
+ }
+
+ contentItem: ColumnLayout {
+ spacing: 4
+
+ Repeater {
+ model: Providers.REGISTRY_TYPES
+
+ delegate: AddRow {
+ required property var modelData
+
+ title: Providers.REGISTRY[modelData] ? Providers.REGISTRY[modelData].name : String(modelData)
+ tag: Providers.REGISTRY[modelData] ? Providers.REGISTRY[modelData].format : ""
+ iconStem: ModelIcons.providerIcon(String(modelData))
+ onChosen: {
+ if (!root.service) return;
+ root.expandedId = root.service.addProvider(String(modelData));
+ addPicker.close();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/lib/ContextWindows.js b/src/lib/ContextWindows.js
new file mode 100644
index 0000000..58bec17
--- /dev/null
+++ b/src/lib/ContextWindows.js
@@ -0,0 +1,109 @@
+.pragma library
+
+// Context windows from a models.dev snapshot + token formatting.
+// Pure JS. NexusService fetches/slims/caches the snapshot; ollama is
+// dynamic (num_ctx / /api/ps) and never registry-matched.
+
+// Plugin registry type → models.dev provider key. Absent → no lookup.
+var PROVIDER_KEYS = {
+ openai: "openai",
+ claude: "anthropic",
+ google: "google",
+ groq: "groq",
+ deepseek: "deepseek",
+ openrouter: "openrouter",
+ perplexity: "perplexity",
+ zai: "zai",
+ moonshot: "moonshotai"
+};
+
+// Reduce a full models.dev snapshot to { key: { id: context } },
+// keeping only models that actually declare limit.context. Bad JSON
+// or malformed shapes degrade to {} — the meter then shows tokens
+// without a window.
+
+function slim(apiJson) {
+ var out = {};
+ var root = null;
+ try { root = JSON.parse(apiJson); } catch (e) { return out; }
+ if (!root || typeof root !== "object") return out;
+ for (var key in root) {
+ var prov = root[key];
+ var models = prov && prov.models;
+ if (!models || typeof models !== "object") continue;
+ var entry = {};
+ for (var id in models) {
+ var limit = models[id] && models[id].limit;
+ var ctx = limit && limit.context;
+ if (typeof ctx === "number" && ctx > 0) entry[id] = ctx;
+ }
+ if (Object.keys(entry).length > 0) out[key] = entry;
+ }
+ return out;
+}
+
+// Drop one trailing "-segment" ("x-4-5-20250929" → "x-4-5"); suffix
+// trimming never prefix-matches INTO longer keys.
+
+function trimSegment(id) {
+ var idx = id.lastIndexOf("-");
+ return idx > 0 ? id.substring(0, idx) : "";
+}
+
+// Window for a model, or 0 when unknown. Exact id first, then
+// progressively trimmed suffixes; exact entries always win over
+// shorter bases.
+
+function lookup(slimMap, providerType, modelId) {
+ if (!slimMap || typeof slimMap !== "object") return 0;
+ var key = PROVIDER_KEYS[providerType];
+ if (!key) return 0;
+ var models = slimMap[key];
+ if (!models) return 0;
+ var id = String(modelId || "").trim();
+ if (!id) return 0;
+ for (;;) {
+ if (typeof models[id] === "number") return models[id];
+ id = trimSegment(id);
+ if (!id) return 0;
+ }
+}
+
+// Human token counts: 0 → "0", 999 → "999", 8192 → "8.2k",
+// 1000000 → "1M", 1183232 → "1.2M". Negative/non-numeric → "".
+
+function formatTokens(n) {
+ if (typeof n !== "number" || !isFinite(n) || n < 0) return "";
+ if (n === 0) return "0";
+ function trim1(v) {
+ var s = v.toFixed(1);
+ return s.charAt(s.length - 1) === "0" ? s.substring(0, s.length - 2) : s;
+ }
+ if (n >= 1e6) return trim1(n / 1e6) + "M";
+ if (n >= 1e3) return trim1(n / 1e3) + "k";
+ return String(Math.round(n));
+}
+
+// Context window from ollama /api/ps (the loaded runner's real
+// context). Exact name match, then colon-less base; 0 when unknown.
+function ollamaWindowFromPs(psJson, model) {
+ var root = null;
+ try { root = JSON.parse(psJson); } catch (e) { return 0; }
+ var models = root && Array.isArray(root.models) ? root.models : [];
+ var wanted = String(model || "").trim();
+ if (!wanted) return 0;
+ var base = wanted.split(":")[0];
+ var exact = null;
+ var loose = null;
+ for (var i = 0; i < models.length; i++) {
+ var m = models[i];
+ if (!m) continue;
+ var name = (typeof m.name === "string" && m.name)
+ || (typeof m.model === "string" && m.model) || "";
+ if (name === wanted) { exact = m; break; }
+ if (!loose && base && name.split(":")[0] === base) loose = m;
+ }
+ var hit = exact || loose;
+ return (hit && typeof hit.context_length === "number"
+ && hit.context_length > 0) ? hit.context_length : 0;
+}
diff --git a/src/lib/ErrorHints.js b/src/lib/ErrorHints.js
new file mode 100644
index 0000000..39b8704
--- /dev/null
+++ b/src/lib/ErrorHints.js
@@ -0,0 +1,35 @@
+.pragma library
+
+// Map HTTP status codes from OpenAI-compatible endpoints to short,
+// human-readable hints appended to error bubbles.
+
+function httpErrorHint(status) {
+ switch (status) {
+ case 400: return "Bad request — the provider rejected the request. Check the model name.";
+ case 401: return "Unauthorized — the API key is missing or invalid.";
+ case 403: return "Forbidden — this key lacks permission for the model.";
+ case 404: return "Not found — check the base URL and model name.";
+ case 413: return "Payload too large — the conversation exceeds the provider's limit.";
+ case 422: return "Unprocessable request — check the model name and parameters.";
+ case 429: return "Rate limited or quota exceeded — wait a moment and retry.";
+ case 500:
+ case 502:
+ case 503:
+ case 504: return "The provider is having trouble (server error). Try again shortly.";
+ default: return "";
+ }
+}
+
+// Map curl exit codes (non-zero) to connection-level hints.
+
+function curlExitHint(exitCode) {
+ switch (exitCode) {
+ case 6: return "Could not resolve the server address — check the base URL and network.";
+ case 7: return "Could not connect to the server — is it running and reachable?";
+ case 28: return "The request timed out.";
+ case 35:
+ case 53: return "TLS handshake failed — check the base URL scheme.";
+ case 60: return "TLS certificate verification failed.";
+ default: return "";
+ }
+}
diff --git a/src/lib/FuzzyMatch.js b/src/lib/FuzzyMatch.js
new file mode 100644
index 0000000..e4df567
--- /dev/null
+++ b/src/lib/FuzzyMatch.js
@@ -0,0 +1,18 @@
+.pragma library
+
+// Fuzzy model-search matcher: case-insensitive subsequence test with
+// whitespace stripped from both sides, so "gpt4o" and "gpt 4o" both
+// match "gpt-4o", "c45" matches "claude-sonnet-4-5", and plain
+// substrings keep working. Pure JS — must stay node-testable.
+
+function matches(query, target) {
+ if (typeof query !== "string" || typeof target !== "string")
+ return false;
+ var q = query.replace(/\s+/g, "").toLowerCase();
+ if (q.length === 0) return true; // no filter → everything passes
+ var t = target.replace(/\s+/g, "").toLowerCase();
+ var i = 0;
+ for (var j = 0; j < t.length && i < q.length; j++)
+ if (t.charCodeAt(j) === q.charCodeAt(i)) i++;
+ return i === q.length;
+}
diff --git a/src/lib/Markdown.js b/src/lib/Markdown.js
new file mode 100644
index 0000000..b242856
--- /dev/null
+++ b/src/lib/Markdown.js
@@ -0,0 +1,303 @@
+.pragma library
+
+// Markdown → Qt rich-text HTML. Built for live rendering: unterminated
+// constructs (open code fences, unclosed bold) degrade to plain text
+// instead of breaking. All input is HTML-escaped; link schemes are
+// restricted to http(s).
+
+function escapeHtml(s) {
+ if (typeof s !== "string") return "";
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+function safeUrl(url) {
+ var u = (url || "").trim();
+ return /^https?:\/\//i.test(u) ? u : "";
+}
+
+// Split message text into text / code segments for the bubble
+// renderer. Mirrors markdownToHtml's fenced-block grammar exactly
+// (including mid-stream unterminated fences) so both render paths
+// never disagree; no input character is dropped.
+function splitCodeSegments(text) {
+ var segs = [];
+ if (typeof text !== "string" || text.length === 0) return segs;
+
+ var re = /```([^\n`]*)\n([\s\S]*?)(?:```|$)/g;
+ var last = 0;
+ var m;
+ while ((m = re.exec(text)) !== null) {
+ if (m.index > last) {
+ var between = text.substring(last, m.index);
+ if (between.length > 0) segs.push({ type: "text", text: between });
+ }
+ segs.push({
+ type: "code",
+ lang: m[1].trim(),
+ code: m[2].replace(/\n$/, "")
+ });
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) {
+ var tail = text.substring(last);
+ if (tail.length > 0) segs.push({ type: "text", text: tail });
+ }
+ return segs;
+}
+
+function styleAttr(colors, key, cssProp) {
+ if (!colors || !colors[key]) return "";
+ return ' style="' + cssProp + ": " + colors[key] + ';"';
+}
+
+// ── Inline formatting (run on escaped text) ──────────────────────
+
+function fmtInline(escaped, colors) {
+ var s = escaped;
+
+ // inline code first; stash rendered spans behind \x01 placeholders so
+ // later rules (emphasis, links, autolinks) cannot reach inside them
+ var codeSpans = [];
+ s = s.replace(/`([^`\n]+)`/g, function (_, code) {
+ codeSpans.push('' + code + "");
+ return "\x01C" + (codeSpans.length - 1) + "\x01";
+ });
+
+ s = s.replace(/\*\*\*([^*\n]+)\*\*\*/g, "$1");
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1");
+ s = s.replace(/\*([^*\n]+)\*/g, "$1");
+ s = s.replace(/__([^_\n]+)__/g, "$1");
+ s = s.replace(/~~([^~\n]+)~~/g, "$1");
+
+ // links: [text](url) — text already escaped, url re-validated
+ s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function (_, text, url) {
+ var u = safeUrl(url.replace(/&/g, "&"));
+ if (!u) return text;
+ return '" + text + "";
+ });
+
+ // autolink bare http(s) URLs
+ s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, function (_, pre, url) {
+ var u = url.replace(/&/g, "&");
+ return pre + '" + url + "";
+ });
+
+ return s.replace(/\x01C(\d+)\x01/g, function (_, idx) {
+ return codeSpans[parseInt(idx, 10)];
+ });
+}
+
+// ── Block pipeline ────────────────────────────────────────────────
+
+function markdownToHtml(text, colors, highlightFn) {
+ if (typeof text !== "string" || text.length === 0) return "";
+
+ // NUL/SOH are reserved as internal placeholder sentinels (\x00B…\x00
+ // fenced blocks, \x01C…\x01 inline code); strip them from raw input so
+ // literal control chars in message text cannot collide with them.
+ text = text.replace(/[\x00\x01]/g, "");
+
+ // 1. Pull out fenced code blocks before anything else so their
+ // content never sees inline formatting. An unterminated fence
+ // (mid-stream) still captures to end-of-text.
+ var blocks = [];
+ var src = text.replace(/```([^\n`]*)\n([\s\S]*?)(?:```|$)/g, function (_, lang, code) {
+ var language = lang.trim();
+ var rendered;
+ if (language && highlightFn)
+ rendered = highlightFn(code.replace(/\n$/, ""), language);
+ else
+ rendered = escapeHtml(code.replace(/\n$/, ""));
+ var html = (language
+ ? '
' +
+ escapeHtml(language) + "
"
+ : "") +
+ '
' +
+ '' +
+ rendered + "
";
+ blocks.push(html);
+ return "\x00B" + (blocks.length - 1) + "\x00";
+ });
+
+ // 2. Escape everything that remains.
+ src = escapeHtml(src);
+
+ // 3. Split into lines and group into blocks.
+ var lines = src.split("\n");
+ var html = "";
+ var i = 0;
+
+ function flushParagraph(buf) {
+ if (buf.length === 0) return;
+ // fmtInline per line: the autolink prefix (^|[\s(]) must see each
+ // line start, which the " " join would otherwise hide
+ html += "