Split the bar panel into one QML file per stage

Panel.qml keeps the state, the CLI calls and the stage switch (889 lines,
was 2360); each stage is its own file taking the panel as a required
property. The omarchy installer copies every QML/JS file and clears stale
ones. tests/static.sh filters qmllint on [syntax], not the word error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
smoido
2026-09-15 02:12:49 +03:00
co-authored by Claude Opus 5
parent 4091746d4e
commit bb01418ce0
17 changed files with 1717 additions and 1510 deletions
+27 -1
View File
@@ -174,4 +174,30 @@ test.
| session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — | | session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — |
| key storage | `linux/cm-vault.sh` | `lib/vault.ps1` | | key storage | `linux/cm-vault.sh` | `lib/vault.ps1` |
| the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — | | the bar icon and state | `omarchy/smoido.claude-mode/BarWidget.qml` | — |
| the panel | `omarchy/smoido.claude-mode/Panel.qml` | — | | what the panel does | `Panel.qml` (state, actions, the processes it runs) | — |
| how one panel stage looks | its own file, below | — |
### The bar widget's files
All in `omarchy/smoido.claude-mode/`. The panel owns every piece of state and
every action; each stage file only draws, from the `panel` it is handed.
| file | what is in it |
|---|---|
| `BarWidget.qml` | the icon, the state it watches (`health.json`, `state.json`, the model cache), the session scan, the provider lookup |
| `Panel.qml` | the panel's state and actions, the CLI processes they run, the hero and the footer, and the stages in stacking order |
| `ModeList.qml` | the modes to switch to, each unfolding its presets |
| `BlockedStage.qml` | a refused switch, with the fix |
| `ServerStage.qml` | a server provider's address and key |
| `PresetStage.qml` | the preset editor |
| `TierStage.qml` | picking one tier's model |
| `NewPresetStage.qml`, `RenameStage.qml`, `DeleteStage.qml` | a preset's lifecycle |
| `SessionsStage.qml` | the running-sessions question before a switch |
| `BrokenSessions.qml`, `RepairStage.qml` | sessions a switch broke, and repairing one |
| `ModelMap.qml` | the active tier-to-model map |
| `PillButton.qml`, `BrandIcon.qml` | the panel's button, and a provider's mark |
| `Modes.js` | fallback presentation for anthropic and an older `health.json` |
A stage cannot reach into another file by id, so where an action has to touch a
stage's field — clearing the model search, focusing a name field — `Panel.qml`
emits a signal and the stage's `Connections` block does it.
+9 -3
View File
@@ -36,11 +36,17 @@ fi
# --- payload --------------------------------------------------------------- # --- payload ---------------------------------------------------------------
mkdir -p "$DEST_DIR" mkdir -p "$DEST_DIR"
for f in manifest.json BarWidget.qml Panel.qml BrandIcon.qml Modes.js; do for f in manifest.json BarWidget.qml Panel.qml Modes.js; do
[ -f "$SRC/$f" ] || { fail "missing from payload: $f"; exit 1; } [ -f "$SRC/$f" ] || { fail "missing from payload: $f"; exit 1; }
install -m 0644 "$SRC/$f" "$DEST_DIR/$f"
done done
green 'plugin files copied' # Every QML and JS file, not a fixed list: the panel is split into one file per
# stage. The old ones are cleared first, so a file removed from the source does
# not linger in the plugin directory, where QML would still find it by name.
rm -f "$DEST_DIR"/*.qml "$DEST_DIR"/*.js
for f in "$SRC"/manifest.json "$SRC"/*.qml "$SRC"/*.js; do
install -m 0644 "$f" "$DEST_DIR/$(basename "$f")"
done
green "plugin files copied ($(ls "$SRC"/*.qml | wc -l | tr -d ' ') QML files)"
# --- bar layout ------------------------------------------------------------ # --- bar layout ------------------------------------------------------------
# shell.json is the user's own file and may carry unrelated customisation, so # shell.json is the user's own file and may carry unrelated customisation, so
@@ -0,0 +1,92 @@
import QtQuick
import qs.Commons
import qs.Ui
// BlockedStage.qml - Preflight refused. Name the missing thing and offer the one action that fixes it, rather than failing the click silently.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "blocked" && stage.panel.blocker !== null
spacing: Style.space(7)
Text {
width: parent.width
text: stage.panel.blocker ? String(stage.panel.blocker.title) : ""
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: stage.panel.blocker ? String(stage.panel.blocker.detail) : ""
color: Color.popups.text
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
visible: stage.panel.blocker && String(stage.panel.blocker.remedy) !== ""
text: stage.panel.blocker ? "$ " + String(stage.panel.blocker.remedy) : ""
color: Color.muted
wrapMode: Text.WrapAnywhere
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont;
label: "Set up " + stage.panel.pTitle(stage.panel.pendingMode) + "…"
primary: true
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "setup"
onTriggered: stage.panel.runSetup()
}
PillButton { fontFamily: stage.panel.uiFont;
label: "Store the key…"
primary: true
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "set-key"
onTriggered: stage.panel.runRemedy()
}
// A preset with every tier empty: the fix is one click away here,
// not in a terminal.
PillButton { fontFamily: stage.panel.uiFont;
label: "Edit preset…"
primary: true
visible: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "edit-preset"
onTriggered: {
var p = stage.panel.pendingPreset
stage.panel.resetFlow()
stage.panel.openPresetEditor(p)
}
}
PillButton { fontFamily: stage.panel.uiFont;
label: "Server settings…"
primary: stage.panel.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(stage.panel.blocker.remedyKind)) >= 0
visible: stage.panel.serverEditable(stage.panel.pendingMode)
&& !(stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "setup")
onTriggered: stage.panel.openServerSettings(stage.panel.pendingPreset)
}
PillButton { fontFamily: stage.panel.uiFont;
label: stage.panel.blocker && ["start-server", "setup"].indexOf(String(stage.panel.blocker.remedyKind)) >= 0
? "Check again" : "Try again"
primary: stage.panel.blocker && String(stage.panel.blocker.remedyKind) === "start-server"
onTriggered: stage.panel.retryPending()
}
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
}
}
@@ -0,0 +1,218 @@
import QtQuick
import qs.Commons
import qs.Ui
// BrokenSessions.qml - Sessions that a switch cut short. Shown in the list, because the point is to be noticed without going looking.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "list" && (stage.panel.broken.length > 0 || stage.panel.ignored.length > 0)
spacing: Style.space(4)
PanelSeparator { width: parent.width }
Text {
width: parent.width
visible: stage.panel.broken.length > 0
text: stage.panel.broken.length === 1
? "1 session cannot be resumed"
: stage.panel.broken.length + " sessions cannot be resumed"
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
font.bold: true
}
Repeater {
model: stage.panel.stage === "list" ? stage.panel.broken : []
Item {
id: brokenRow
required property var modelData
width: stage.width
height: Style.space(26)
Column {
anchors.left: parent.left
anchors.right: ignoreBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: String(brokenRow.modelData.sessionId).substring(0, 8)
+ " · " + stage.panel.projectLabel(brokenRow.modelData.project)
color: Color.popups.text
elide: Text.ElideMiddle
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: brokenRow.modelData.dropLines + " turn lines after the last good message"
color: Color.muted
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
}
PillButton { fontFamily: stage.panel.uiFont;
id: ignoreBtn
anchors.right: repairBtn.left
anchors.rightMargin: Style.space(5)
anchors.verticalCenter: parent.verticalCenter
label: "Ignore"
onTriggered: stage.panel.setIgnored(brokenRow.modelData, true)
}
PillButton { fontFamily: stage.panel.uiFont;
id: repairBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Repair"
onTriggered: stage.panel.askRepair(brokenRow.modelData)
}
}
}
// ---- Hidden: dismissed here, or broken but untouched for longer than
// the age limit. Collapsed, because the point of hiding them was that
// they stop taking up attention - but never gone without a trace.
Item {
width: parent.width
height: Style.space(20)
visible: stage.panel.ignored.length > 0
Text {
id: hiddenLabel
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "hidden (" + stage.panel.ignored.length + ")"
color: hiddenArea.containsMouse ? Color.popups.text : Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
anchors.left: hiddenLabel.right
anchors.leftMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
text: String.fromCodePoint(stage.panel.hiddenExpanded ? 0xF0140 : 0xF0142)
color: hiddenLabel.color
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
MouseArea {
id: hiddenArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: stage.panel.hiddenExpanded = !stage.panel.hiddenExpanded
}
}
Column {
width: parent.width
visible: stage.panel.hiddenExpanded && stage.panel.ignored.length > 0
spacing: Style.space(3)
Text {
visible: stage.panel.dismissedList.length > 0
text: "Ignored (" + stage.panel.dismissedList.length + ")"
color: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
font.bold: true
}
Repeater {
model: stage.panel.hiddenExpanded ? stage.panel.dismissedList.slice(0, stage.panel.hiddenCap) : []
Item {
id: dismissedRow
required property var modelData
width: stage.width
height: Style.space(26)
Text {
anchors.left: parent.left
anchors.right: restoreBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: String(dismissedRow.modelData.sessionId).substring(0, 8)
+ " · " + stage.panel.projectLabel(dismissedRow.modelData.project)
color: Color.muted
elide: Text.ElideMiddle
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
PillButton { fontFamily: stage.panel.uiFont;
id: restoreBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Restore"
onTriggered: stage.panel.setIgnored(dismissedRow.modelData, false)
}
}
}
Text {
visible: stage.panel.dismissedList.length > stage.panel.hiddenCap
text: "… and " + (stage.panel.dismissedList.length - stage.panel.hiddenCap)
+ " more · claude-mode repair-session --ignored"
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
Text {
visible: stage.panel.staleList.length > 0
text: "Older than " + stage.panel.ageDays + " days (" + stage.panel.staleList.length + ")"
color: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
font.bold: true
}
Repeater {
model: stage.panel.hiddenExpanded ? stage.panel.staleList.slice(0, stage.panel.hiddenCap) : []
Text {
required property var modelData
width: stage.width
text: String(modelData.sessionId).substring(0, 8)
+ " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d"
+ " · " + stage.panel.projectLabel(modelData.project)
color: Color.muted
elide: Text.ElideMiddle
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
}
Text {
width: parent.width
visible: stage.panel.staleList.length > 0
text: (stage.panel.staleList.length > stage.panel.hiddenCap
? "… and " + (stage.panel.staleList.length - stage.panel.hiddenCap) + " more. " : "")
+ "Not counted because nothing has touched them since. "
+ "claude-mode repair-session --all --max-age 0 lists them."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
}
}
@@ -0,0 +1,57 @@
import QtQuick
import qs.Commons
import qs.Ui
// DeleteStage.qml - Delete: confirmed, and says so when it leaves a provider empty.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "presetDelete"
spacing: Style.space(7)
readonly property bool lastOne: stage.panel.stage === "presetDelete"
&& stage.panel.presetsOf(stage.panel.editProvider).length <= 1
Text {
width: parent.width
text: "Delete '" + stage.panel.editPreset + "'?"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: "The preset file is removed, and there is no undo from here. Its key stays in the vault."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: parent.lastOne
text: "It is the only " + stage.panel.pTitle(stage.panel.editProvider) + " preset, so "
+ stage.panel.pTitle(stage.panel.editProvider) + " will have nothing to switch to until you create another."
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Delete it"; primary: true; onTriggered: stage.panel.deletePreset() }
PillButton { fontFamily: stage.panel.uiFont; label: "Back"; onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" } }
}
}
+276
View File
@@ -0,0 +1,276 @@
import QtQuick
import qs.Commons
import qs.Ui
import "Modes.js" as Modes
// ModeList.qml - the modes to switch to, each unfolding its presets.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "list"
spacing: Style.space(10)
PanelSectionHeader {
text: "SWITCH TO"
visible: stage.panel.stage === "list"
foreground: Color.popups.text
fontFamily: stage.panel.uiFont
}
// ---- The modes, current one marked rather than hidden. The CLI omits
// the active mode because a list you arrow through should not offer a
// no-op; here the list is also the status display, so it stays.
Column {
width: parent.width
visible: stage.panel.stage === "list"
spacing: Style.space(1)
Repeater {
model: stage.panel.stage === "list" ? stage.panel.modeOrder : []
Column {
id: modeEntry
required property var modelData
width: stage.width
readonly property string thisMode: String(modelData)
readonly property bool isCurrent: thisMode === stage.panel.mode
readonly property bool isExpanded: stage.panel.expandedMode === thisMode
readonly property var presetList: Modes.presetsFor(stage.panel.health, thisMode, stage.panel.defaultPresets)
Item {
width: parent.width
height: Style.space(30)
Rectangle {
anchors.fill: parent
anchors.leftMargin: -Style.space(6)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: modeEntry.isCurrent
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.12)
: (modeHover.containsMouse
? Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.07)
: "transparent")
}
BrandIcon {
id: modeGlyph
anchors.left: parent.left
anchors.leftMargin: Style.space(3)
anchors.verticalCenter: parent.verticalCenter
pathData: stage.panel.pLogo(modeEntry.thisMode)
opticalScale: stage.panel.pLogoScale(modeEntry.thisMode)
color: modeEntry.isCurrent ? Color.accent : Color.popups.text
iconSize: Style.space(16)
}
Column {
anchors.left: modeGlyph.right
anchors.leftMargin: Style.space(8)
anchors.right: modeMark.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
text: stage.panel.pTitle(modeEntry.thisMode)
color: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.font.body
font.bold: modeEntry.isCurrent
}
Text {
width: parent.width
text: stage.panel.pBlurb(modeEntry.thisMode)
color: Color.muted
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
}
// Checkmark on the active mode; a chevron on a gateway row that
// has a preset list waiting behind it.
Text {
id: modeMark
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: modeEntry.isCurrent
? String.fromCodePoint(0xF012C)
: (Modes.needsPreset(modeEntry.thisMode)
? String.fromCodePoint(modeEntry.isExpanded ? 0xF0140 : 0xF0142)
: "")
color: modeEntry.isCurrent ? Color.accent : Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.font.body
}
MouseArea {
id: modeHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !stage.panel.busy
onClicked: stage.panel.activate(modeEntry.thisMode)
}
}
// ---- Presets for this provider, unfolded in place. Shown even
// with none left, so "New preset…" is still there to click.
Column {
width: parent.width
visible: modeEntry.isExpanded
spacing: Style.space(1)
Repeater {
model: modeEntry.isExpanded ? modeEntry.presetList : []
Item {
id: presetRow
required property var modelData
width: modeEntry.width
height: Style.space(26)
readonly property bool isActive: modeEntry.isCurrent && String(modelData.name) === stage.panel.preset
Rectangle {
anchors.fill: parent
anchors.leftMargin: Style.space(20)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: presetHover.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
: "transparent"
}
Text {
id: presetName
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: presetRow.modelData.name
color: presetRow.isActive ? Color.accent : Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(12)
font.bold: presetRow.isActive
}
// Only worth saying when there is a choice to tell apart.
Text {
id: defaultTag
visible: modeEntry.presetList.length > 1
&& String(presetRow.modelData.name) === stage.panel.defaultPresets[modeEntry.thisMode]
anchors.left: presetName.right
anchors.leftMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: "default"
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
// The opus mapping is the one that tells you what you are
// about to be talking to; the rest is in the tooltip.
Text {
anchors.left: defaultTag.visible ? defaultTag.right : presetName.right
anchors.leftMargin: Style.space(8)
anchors.right: gearButton.visible ? gearButton.left : parent.right
anchors.rightMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: presetRow.modelData.models && presetRow.modelData.models.opus
? String(presetRow.modelData.models.opus)
: ""
color: Color.muted
elide: Text.ElideLeft
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
// Clicking the row switches; the gear opens that preset in the
// editor instead. Per row rather than per provider, because
// two presets of one provider can map tiers differently - and
// two LM Studio presets can sit on two different machines.
MouseArea {
id: presetHover
anchors.fill: parent
anchors.rightMargin: gearButton.visible ? gearButton.width : 0
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !stage.panel.busy
onClicked: stage.panel.switchTo(modeEntry.thisMode, String(presetRow.modelData.name))
}
Item {
id: gearButton
visible: true
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: Style.space(22)
height: parent.height
Text {
anchors.centerIn: parent
text: String.fromCodePoint(0xF0493)
color: gearArea.containsMouse ? Color.accent : Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(12)
}
MouseArea {
id: gearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !stage.panel.busy
onClicked: stage.panel.openPresetEditor(String(presetRow.modelData.name))
}
}
}
}
Item {
width: modeEntry.width
height: Style.space(24)
Rectangle {
anchors.fill: parent
anchors.leftMargin: Style.space(20)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: newPresetArea.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
: "transparent"
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(30)
anchors.verticalCenter: parent.verticalCenter
text: "New " + stage.panel.pTitle(modeEntry.thisMode) + " preset…"
color: newPresetArea.containsMouse ? Color.accent : Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
MouseArea {
id: newPresetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !stage.panel.busy
onClicked: stage.panel.openNewPreset(modeEntry.thisMode, null)
}
}
}
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import QtQuick
import qs.Commons
import qs.Ui
// ModelMap.qml - the active preset's tier-to-model map, behind a gateway.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list"
spacing: Style.space(10)
PanelSeparator { width: parent.width; visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list" }
Column {
width: parent.width
spacing: Style.space(1)
visible: stage.panel.modelRows.length > 0 && stage.panel.stage === "list"
PanelSectionHeader {
text: "MODEL MAP"
foreground: Color.popups.text
fontFamily: stage.panel.uiFont
}
Repeater {
model: stage.panel.modelRows
Item {
required property var modelData
width: stage.width
height: Style.space(19)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.tier
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(54)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: parent.modelData.id
color: Color.popups.text
elide: Text.ElideLeft
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
}
}
}
}
@@ -0,0 +1,115 @@
import QtQuick
import qs.Commons
import qs.Ui
// NewPresetStage.qml - New preset: a name, and what to start from.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "presetNew"
spacing: Style.space(7)
Text {
width: parent.width
text: "New " + stage.panel.pTitle(stage.panel.newProvider) + " preset"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
TextField {
id: newNameField
width: parent.width
text: stage.panel.newName
placeholderText: "name"
foreground: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
onTextChanged: stage.panel.newName = text
onAccepted: stage.panel.createPreset()
}
Text {
width: parent.width
readonly property string problem: stage.panel.stage === "presetNew" ? stage.panel.nameProblem(stage.panel.newName, "") : ""
visible: problem !== ""
text: problem
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
text: "Start from"
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
// Only this provider's presets: a copy keeps the provider, and a
// preset cannot change provider afterwards.
Flow {
width: parent.width
spacing: Style.space(6)
Repeater {
model: stage.panel.stage === "presetNew" ? stage.panel.presetsOf(stage.panel.newProvider) : []
PillButton { fontFamily: stage.panel.uiFont;
required property var modelData
label: "copy of " + String(modelData.name)
primary: stage.panel.newFrom === String(modelData.name)
onTriggered: stage.panel.newFrom = String(modelData.name)
}
}
PillButton { fontFamily: stage.panel.uiFont;
label: "blank"
primary: stage.panel.newFrom === ""
onTriggered: stage.panel.newFrom = ""
}
}
Text {
id: newFromHint
width: parent.width
text: stage.panel.newFrom === ""
? "Every tier starts empty; the editor opens next to fill them in."
: "Same server, key and models as '" + stage.panel.newFrom + "'. The editor opens next to change them."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Create"; primary: true; onTriggered: stage.panel.createPreset() }
PillButton { fontFamily: stage.panel.uiFont;
label: "Cancel"
onTriggered: {
stage.panel.lastError = ""
if (stage.panel.editPreset !== "") stage.panel.stage = "preset"
else stage.panel.resetFlow()
}
}
}
Connections {
target: stage.panel
function onNewPresetOpened() {
newNameField.text = stage.panel.newName
Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() })
}
}
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import QtQuick
import qs.Commons
// PillButton.qml - the panel's button: a bordered pill, filled when primary.
// `enabled: false` greys it out.
Rectangle {
id: pb
property string label: ""
property bool primary: false
// The bar's font; this file cannot see the panel, so it is handed in.
property string fontFamily: Style.font.family
signal triggered()
implicitWidth: pbText.implicitWidth + Style.space(20)
implicitHeight: Style.space(25)
radius: Style.space(4)
// `enabled: false` greys it out; the MouseArea inherits the flag.
opacity: enabled ? 1.0 : 0.4
readonly property color tint: primary ? Color.accent : Color.popups.text
color: pbArea.containsMouse
? Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.30 : 0.14)
: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.16 : 0.00)
border.width: 1
border.color: Qt.rgba(tint.r, tint.g, tint.b, primary ? 0.75 : 0.30)
Text {
id: pbText
anchors.centerIn: parent
text: pb.label
color: Color.popups.text
font.family: pb.fontFamily
font.pixelSize: Style.space(11)
font.bold: pb.primary
}
MouseArea {
id: pbArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: pb.triggered()
}
}
+162
View File
@@ -0,0 +1,162 @@
import QtQuick
import qs.Commons
import qs.Ui
// PresetStage.qml - One preset: its tier map, each row opening a picker.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "preset" && stage.panel.editPreset !== ""
spacing: Style.space(6)
Text {
width: parent.width
text: "'" + stage.panel.editPreset + "' · " + stage.panel.pTitle(stage.panel.editProvider)
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
visible: text !== ""
text: stage.panel.editEntry && stage.panel.editEntry.description ? String(stage.panel.editEntry.description) : ""
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: stage.panel.editIsActive
text: "In use now. A change is applied straight away and reaches sessions started after it."
color: Color.accent
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: stage.panel.editIsDefault
text: "claude-mode " + stage.panel.editProvider + " picks this one when no preset is named"
+ (stage.panel.editIsChosenDefault ? "." : " (the built-in choice).")
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Column {
width: parent.width
spacing: Style.space(1)
Repeater {
model: stage.panel.stage === "preset" ? stage.panel.tierNames : []
Item {
id: tierRow
required property var modelData
readonly property string tier: String(modelData)
readonly property string current: stage.panel.editCurrent(tier)
width: stage.width
height: Style.space(26)
Rectangle {
anchors.fill: parent
anchors.leftMargin: -Style.space(6)
anchors.rightMargin: -Style.space(6)
radius: Style.space(4)
color: tierArea.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.14)
: "transparent"
}
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: tierRow.tier
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(54)
anchors.right: tierChevron.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: tierRow.current !== "" ? tierRow.current : "not set"
color: tierRow.current !== "" ? Color.popups.text : Color.muted
elide: Text.ElideLeft
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
Text {
id: tierChevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String.fromCodePoint(0xF0142)
color: tierArea.containsMouse ? Color.accent : Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.font.body
}
MouseArea {
id: tierArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !stage.panel.busy
onClicked: stage.panel.openTierEditor(tierRow.tier)
}
}
}
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont;
label: "Server settings…"
visible: stage.panel.serverEditable(stage.panel.editProvider)
onTriggered: stage.panel.openServerSettings(stage.panel.editPreset, "preset")
}
PillButton { fontFamily: stage.panel.uiFont; label: "Make default"; visible: !stage.panel.editIsDefault; onTriggered: stage.panel.setDefault(true) }
PillButton { fontFamily: stage.panel.uiFont; label: "Clear default"; visible: stage.panel.editIsChosenDefault; onTriggered: stage.panel.setDefault(false) }
PillButton { fontFamily: stage.panel.uiFont; label: "Duplicate…"; onTriggered: stage.panel.openNewPreset(stage.panel.editProvider, stage.panel.editPreset) }
PillButton { fontFamily: stage.panel.uiFont; label: "Rename…"; onTriggered: stage.panel.openRename() }
// The CLI refuses to delete the preset in use. A button that always
// fails would be worse than one that says why it is off.
PillButton { fontFamily: stage.panel.uiFont;
label: "Delete…"
enabled: !stage.panel.editIsActive
onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "presetDelete" }
}
PillButton { fontFamily: stage.panel.uiFont; label: "Done"; primary: true; onTriggered: stage.panel.resetFlow() }
}
Text {
width: parent.width
visible: stage.panel.editIsActive
text: "In use, so it cannot be deleted. Switch to another preset first."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.Commons
import qs.Ui
// RenameStage.qml - Rename: the file moves, and state.json follows if it is in use.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "presetRename"
spacing: Style.space(7)
Text {
width: parent.width
text: "Rename '" + stage.panel.editPreset + "'"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
TextField {
id: renameField
width: parent.width
text: stage.panel.renameTo
placeholderText: "new name"
foreground: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
onTextChanged: stage.panel.renameTo = text
onAccepted: stage.panel.renamePreset()
}
Text {
width: parent.width
readonly property string problem: stage.panel.stage === "presetRename" ? stage.panel.nameProblem(stage.panel.renameTo, stage.panel.editPreset) : ""
visible: problem !== ""
text: problem
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: stage.panel.editIsActive
text: "It is the preset in use. The switch follows the new name, and running sessions keep working."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Rename"; primary: true; onTriggered: stage.panel.renamePreset() }
PillButton { fontFamily: stage.panel.uiFont; label: "Back"; onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" } }
}
Connections {
target: stage.panel
function onRenameOpened() {
renameField.text = stage.panel.renameTo
Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() })
}
}
}
@@ -0,0 +1,62 @@
import QtQuick
import qs.Commons
import qs.Ui
// RepairStage.qml - Confirming one repair.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "repair" && stage.panel.repairTarget !== null
spacing: Style.space(7)
Text {
width: parent.width
text: "Repair " + (stage.panel.repairTarget ? String(stage.panel.repairTarget.sessionId).substring(0, 8) : "")
color: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: {
if (!stage.panel.repairTarget) return ""
var p = (stage.panel.repairTarget.providers || []).join(", ")
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
+ " output while the mode was switched under it, and Anthropic will not "
+ "resume it. Rolling it back to its last good message drops "
+ stage.panel.repairTarget.dropLines + " lines."
}
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: "The original is backed up, the dropped turns are saved as Markdown, "
+ "and handed back to the session so it still knows what it did."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Repair it"; primary: true; onTriggered: stage.panel.doRepair() }
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
}
}
+132
View File
@@ -0,0 +1,132 @@
import QtQuick
import qs.Commons
import qs.Ui
// ServerStage.qml - Where a server provider is, and whether it needs a key.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "server"
spacing: Style.space(8)
Text {
width: parent.width
text: "Server for '" + stage.panel.serverPreset + "'"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: stage.panel.serverHint
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
TextField {
id: urlField
width: parent.width
text: stage.panel.serverUrl
placeholderText: stage.panel.serverDefault !== "" ? stage.panel.serverDefault : "https://…"
foreground: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
onTextChanged: stage.panel.serverUrl = text
onAccepted: stage.panel.saveServerSettings()
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
// A custom endpoint has no usual address to go back to.
PillButton { fontFamily: stage.panel.uiFont;
label: "Use local default"
visible: stage.panel.serverDefault !== ""
onTriggered: { stage.panel.serverUrl = stage.panel.serverDefault; urlField.text = stage.panel.serverDefault }
}
}
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(9)
ToggleSwitch {
id: authToggle
anchors.verticalCenter: parent.verticalCenter
checked: stage.panel.serverNeedsKey
foreground: Color.popups.text
accent: Color.accent
onToggled: stage.panel.serverNeedsKey = !stage.panel.serverNeedsKey
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - authToggle.width - parent.spacing
spacing: Style.space(1)
Text {
width: parent.width
text: "Server requires an API key"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
}
Text {
width: parent.width
text: stage.panel.serverNeedsKey
? "Kept in the vault as '" + stage.panel.serverKeyRef + "', never in settings.json."
: "Sends " + stage.panel.pTitle(stage.panel.serverProvider) + "'s placeholder token, which is not a secret."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
}
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
visible: stage.panel.serverNeedsKey
PillButton { fontFamily: stage.panel.uiFont; label: "Store the key…"; onTriggered: stage.panel.storeServerKey() }
}
PanelSeparator { width: parent.width }
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Save"; primary: true; onTriggered: stage.panel.saveServerSettings() }
PillButton { fontFamily: stage.panel.uiFont;
label: stage.panel.serverReturn !== "" ? "Back" : "Cancel"
onTriggered: {
if (stage.panel.serverReturn !== "") stage.panel.stage = stage.panel.serverReturn
else stage.panel.resetFlow()
}
}
}
}
@@ -0,0 +1,126 @@
import QtQuick
import qs.Commons
import qs.Ui
// SessionsStage.qml - Sessions are running. They will keep the old provider until they are restarted, and one mid-request can lose that turn outright, so this is a decision rather than a notification.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "confirm" && stage.panel.sessionInfo !== null
spacing: Style.space(7)
readonly property int total: stage.panel.sessionInfo ? Number(stage.panel.sessionInfo.count) : 0
readonly property int busyCount: stage.panel.sessionInfo ? Number(stage.panel.sessionInfo.busy) : 0
Text {
width: parent.width
text: parent.total === 1
? "1 Claude session is running"
: parent.total + " Claude sessions are running"
color: Color.popups.text
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
// Not "they will keep using the old provider". Their endpoint and model
// ids are fixed at startup, but the key is re-fetched on a timer and
// will resolve to the new mode, which the old endpoint refuses - so
// they fail rather than carry on.
Text {
width: parent.width
text: "They keep pointing at " + stage.panel.pTitle(stage.panel.mode) + ", but their key is "
+ "re-fetched on a timer and will switch under them. Worse, if one takes a "
+ "reply from the new provider first, that provider's message-id format "
+ "goes into its transcript and Anthropic will refuse to resume it at all."
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: parent.busyCount > 0
text: parent.busyCount === 1
? "One is working right now and will break mid-turn."
: parent.busyCount + " are working right now and will break mid-turn."
color: stage.panel.urgentColor
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Column {
width: parent.width
spacing: Style.space(1)
Repeater {
model: stage.panel.sessionInfo ? stage.panel.sessionInfo.sessions : []
Item {
required property var modelData
width: stage.width
height: Style.space(17)
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.tty
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
anchors.left: parent.left
anchors.leftMargin: Style.space(52)
anchors.right: busyTag.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
text: parent.modelData.cwd
color: Color.popups.text
elide: Text.ElideLeft
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
id: busyTag
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: parent.modelData.busy === true
text: "working"
color: stage.panel.urgentColor
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
}
}
}
// Flow, not Row: these can total more than the card is wide, and a
// Row lays them straight past its right edge instead of wrapping.
Flow {
width: parent.width
spacing: Style.space(7)
// Restart is the only option that ends with every session on the mode
// the bar is now claiming, so it leads and it is the primary.
PillButton { fontFamily: stage.panel.uiFont;
label: "Switch and restart"
primary: true
onTriggered: stage.panel.applySwitch("restart")
}
PillButton { fontFamily: stage.panel.uiFont; label: "Switch and close"; onTriggered: stage.panel.applySwitch("stop") }
PillButton { fontFamily: stage.panel.uiFont; label: "Switch only"; onTriggered: stage.panel.applySwitch("none") }
PillButton { fontFamily: stage.panel.uiFont; label: "Cancel"; onTriggered: stage.panel.resetFlow() }
}
}
+223
View File
@@ -0,0 +1,223 @@
import QtQuick
import qs.Commons
import qs.Ui
// TierStage.qml - One tier: pick from the cached catalogue, or type any id.
//
// One stage of the claude-mode panel. Panel.qml hands itself in as `panel`
// and owns all the state and actions; this file only draws them.
Column {
id: stage
required property var panel
width: parent.width
visible: stage.panel.stage === "presetTier"
spacing: Style.space(7)
Text {
width: parent.width
text: stage.panel.editTier + " · '" + stage.panel.editPreset + "'"
color: Color.popups.text
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: "now: " + (stage.panel.editCurrent(stage.panel.editTier) || "not set")
color: Color.muted
elide: Text.ElideLeft
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
// One field that both filters the catalogue and takes any id typed by
// hand, with the matches listed inline beneath it - rather than the
// shell's SearchableDropdown, whose list is a second popup layered on
// this card. Inline, the card's height is the only thing to manage,
// and the list can never be clipped by the card's edge.
TextField {
id: modelField
width: parent.width
text: stage.panel.editModel
placeholderText: stage.panel.modelOptions.length > 0
? "Search " + stage.panel.modelOptions.length + " models, or type any id"
: "Model id"
foreground: Color.popups.text
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
onTextChanged: {
stage.panel.editModel = text
modelList.currentIndex = -1
}
// Enter takes the highlighted match if the arrows chose one, and
// saves what is in the field otherwise.
onAccepted: {
if (modelList.currentIndex >= 0 && modelList.currentIndex < stage.panel.modelMatches.length)
stage.panel.pickModel(String(stage.panel.modelMatches[modelList.currentIndex].value))
else
stage.panel.saveTier()
}
Keys.onDownPressed: function (event) {
if (modelList.count > 0) {
modelList.currentIndex = Math.min(modelList.currentIndex + 1, modelList.count - 1)
modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
}
event.accepted = true
}
Keys.onUpPressed: function (event) {
if (modelList.currentIndex >= 0) {
modelList.currentIndex = modelList.currentIndex - 1
if (modelList.currentIndex >= 0) modelList.positionViewAtIndex(modelList.currentIndex, ListView.Contain)
}
event.accepted = true
}
}
// Scrolls inside a box of at most six rows: the card clamps to its
// content rather than scrolling, so an unbounded list would push the
// buttons off the bottom edge.
Rectangle {
id: modelListFrame
readonly property int rowHeight: Style.space(30)
width: parent.width
visible: stage.panel.modelOptions.length > 0
height: rowHeight * Math.max(1, Math.min(modelList.count, 6)) + 2
radius: Style.space(4)
color: "transparent"
border.width: 1
border.color: Qt.rgba(Color.popups.text.r, Color.popups.text.g, Color.popups.text.b, 0.14)
ListView {
id: modelList
anchors.fill: parent
anchors.margins: 1
clip: true
boundsBehavior: Flickable.StopAtBounds
currentIndex: -1
model: stage.panel.stage === "presetTier" ? stage.panel.modelMatches : []
delegate: Item {
id: matchRow
required property var modelData
required property int index
readonly property bool chosen: String(modelData.value) === stage.panel.editModel.trim()
width: modelList.width
height: modelListFrame.rowHeight
Rectangle {
anchors.fill: parent
color: matchRow.index === modelList.currentIndex || matchArea.containsMouse
? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.16)
: (matchRow.chosen ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.10) : "transparent")
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Style.space(8)
anchors.rightMargin: Style.space(8)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: String(matchRow.modelData.label)
color: matchRow.chosen ? Color.accent : Color.popups.text
elide: Text.ElideMiddle
font.family: stage.panel.uiFont
font.pixelSize: Style.space(11)
font.bold: matchRow.chosen
}
Text {
width: parent.width
visible: text !== ""
text: String(matchRow.modelData.description || "")
color: Color.muted
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
}
MouseArea {
id: matchArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: stage.panel.pickModel(String(matchRow.modelData.value))
}
}
}
Text {
anchors.centerIn: parent
visible: modelList.count === 0
text: "No match. Save uses the id as typed."
color: Color.muted
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
}
Text {
width: parent.width
visible: stage.panel.modelOptions.length === 0
text: stage.panel.catalogueNode && stage.panel.catalogueNode.ok === false
? "The last fetch failed and no list is cached. Type an id, or try fetching again."
: "No model list cached for " + stage.panel.pTitle(stage.panel.editProvider) + " yet. Type an id, or fetch the list."
color: Color.muted
wrapMode: Text.WordWrap
font.family: stage.panel.uiFont
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
visible: stage.panel.catalogueNode !== null && stage.panel.modelOptions.length > 0
text: stage.panel.catalogueNode
? stage.panel.modelOptions.length + " models cached, fetched " + stage.panel.ageLabel(stage.panel.catalogueNode.fetchedAt)
+ (stage.panel.catalogueNode.ok === false ? " · the last refresh failed" : "")
: ""
color: Color.muted
opacity: 0.85
elide: Text.ElideRight
font.family: stage.panel.uiFont
font.pixelSize: Style.space(9)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { fontFamily: stage.panel.uiFont; label: "Save"; primary: true; onTriggered: stage.panel.saveTier() }
PillButton { fontFamily: stage.panel.uiFont;
label: stage.panel.modelOptions.length > 0 ? "Refresh list" : "Fetch models"
onTriggered: stage.panel.fetchModels()
}
PillButton { fontFamily: stage.panel.uiFont;
label: "Back"
onTriggered: { stage.panel.lastError = ""; stage.panel.stage = "preset" }
}
}
// The panel cannot reach this field by id, so when a tier is opened or a
// model picked it says so, and the field is reset or filled here. Typing
// breaks the field's binding, so without the reset a value left from another
// tier would survive into this one.
Connections {
target: stage.panel
function onTierEditorOpened() {
modelField.text = ""
modelList.currentIndex = -1
Qt.callLater(function () { modelField.forceActiveFocus() })
}
function onModelPicked(modelId) {
modelField.text = modelId
modelField.forceActiveFocus()
}
}
}
+3 -1
View File
@@ -160,10 +160,12 @@ QMLLINT="$(command -v qmllint || true)"
if [ -n "$QMLLINT" ]; then if [ -n "$QMLLINT" ]; then
# The shell's own modules (qs.*, Quickshell) do not resolve outside it, so # The shell's own modules (qs.*, Quickshell) do not resolve outside it, so
# only real syntax errors count; unresolved-type warnings are expected. # only real syntax errors count; unresolved-type warnings are expected.
# qmllint reports a syntax error as a *warning* tagged [syntax] - matching
# on the word "error" instead caught every file that mentions lastError.
qml_syntax() { qml_syntax() {
local f out local f out
for f in omarchy/smoido.claude-mode/*.qml; do for f in omarchy/smoido.claude-mode/*.qml; do
out="$("$QMLLINT" "$f" 2>&1 | grep -iE 'error|expected token|syntax|unexpected|duplicate' || true)" out="$("$QMLLINT" "$f" 2>&1 | grep -F '[syntax]' || true)"
[ -z "$out" ] || { echo "$f"; echo "$out"; return 1; } [ -z "$out" ] || { echo "$f"; echo "$out"; return 1; }
done done
} }