From bb01418ce0ba3aba5018328a787a3484dd5d73b6 Mon Sep 17 00:00:00 2001 From: smoido Date: Tue, 15 Sep 2026 02:12:49 +0300 Subject: [PATCH] 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) --- docs/architecture.md | 28 +- omarchy/install.sh | 12 +- omarchy/smoido.claude-mode/BlockedStage.qml | 92 + omarchy/smoido.claude-mode/BrokenSessions.qml | 218 +++ omarchy/smoido.claude-mode/DeleteStage.qml | 57 + omarchy/smoido.claude-mode/ModeList.qml | 276 +++ omarchy/smoido.claude-mode/ModelMap.qml | 62 + omarchy/smoido.claude-mode/NewPresetStage.qml | 115 ++ omarchy/smoido.claude-mode/Panel.qml | 1539 +---------------- omarchy/smoido.claude-mode/PillButton.qml | 44 + omarchy/smoido.claude-mode/PresetStage.qml | 162 ++ omarchy/smoido.claude-mode/RenameStage.qml | 75 + omarchy/smoido.claude-mode/RepairStage.qml | 62 + omarchy/smoido.claude-mode/ServerStage.qml | 132 ++ omarchy/smoido.claude-mode/SessionsStage.qml | 126 ++ omarchy/smoido.claude-mode/TierStage.qml | 223 +++ tests/static.sh | 4 +- 17 files changed, 1717 insertions(+), 1510 deletions(-) create mode 100644 omarchy/smoido.claude-mode/BlockedStage.qml create mode 100644 omarchy/smoido.claude-mode/BrokenSessions.qml create mode 100644 omarchy/smoido.claude-mode/DeleteStage.qml create mode 100644 omarchy/smoido.claude-mode/ModeList.qml create mode 100644 omarchy/smoido.claude-mode/ModelMap.qml create mode 100644 omarchy/smoido.claude-mode/NewPresetStage.qml create mode 100644 omarchy/smoido.claude-mode/PillButton.qml create mode 100644 omarchy/smoido.claude-mode/PresetStage.qml create mode 100644 omarchy/smoido.claude-mode/RenameStage.qml create mode 100644 omarchy/smoido.claude-mode/RepairStage.qml create mode 100644 omarchy/smoido.claude-mode/ServerStage.qml create mode 100644 omarchy/smoido.claude-mode/SessionsStage.qml create mode 100644 omarchy/smoido.claude-mode/TierStage.qml diff --git a/docs/architecture.md b/docs/architecture.md index 757b192..c423ead 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -174,4 +174,30 @@ test. | session repair | `linux/lib/repair.sh`; `cm-json.py` `cmd_scan_sessions`, `cmd_repair_session` | — | | key storage | `linux/cm-vault.sh` | `lib/vault.ps1` | | 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. diff --git a/omarchy/install.sh b/omarchy/install.sh index 0177d00..5143d7f 100755 --- a/omarchy/install.sh +++ b/omarchy/install.sh @@ -36,11 +36,17 @@ fi # --- payload --------------------------------------------------------------- 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; } - install -m 0644 "$SRC/$f" "$DEST_DIR/$f" 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 ------------------------------------------------------------ # shell.json is the user's own file and may carry unrelated customisation, so diff --git a/omarchy/smoido.claude-mode/BlockedStage.qml b/omarchy/smoido.claude-mode/BlockedStage.qml new file mode 100644 index 0000000..e2df218 --- /dev/null +++ b/omarchy/smoido.claude-mode/BlockedStage.qml @@ -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() } + } +} diff --git a/omarchy/smoido.claude-mode/BrokenSessions.qml b/omarchy/smoido.claude-mode/BrokenSessions.qml new file mode 100644 index 0000000..3a6683b --- /dev/null +++ b/omarchy/smoido.claude-mode/BrokenSessions.qml @@ -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) + } + } +} diff --git a/omarchy/smoido.claude-mode/DeleteStage.qml b/omarchy/smoido.claude-mode/DeleteStage.qml new file mode 100644 index 0000000..2f88f71 --- /dev/null +++ b/omarchy/smoido.claude-mode/DeleteStage.qml @@ -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" } } + } +} diff --git a/omarchy/smoido.claude-mode/ModeList.qml b/omarchy/smoido.claude-mode/ModeList.qml new file mode 100644 index 0000000..ec74a5a --- /dev/null +++ b/omarchy/smoido.claude-mode/ModeList.qml @@ -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) + } + } + } + } + } + } +} diff --git a/omarchy/smoido.claude-mode/ModelMap.qml b/omarchy/smoido.claude-mode/ModelMap.qml new file mode 100644 index 0000000..b91656a --- /dev/null +++ b/omarchy/smoido.claude-mode/ModelMap.qml @@ -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) + } + } + } + } +} diff --git a/omarchy/smoido.claude-mode/NewPresetStage.qml b/omarchy/smoido.claude-mode/NewPresetStage.qml new file mode 100644 index 0000000..a14e31e --- /dev/null +++ b/omarchy/smoido.claude-mode/NewPresetStage.qml @@ -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() }) + } + } +} diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml index 0a532be..acc3b6a 100644 --- a/omarchy/smoido.claude-mode/Panel.qml +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -28,6 +28,16 @@ Panel { property string expandedMode: "" property bool busy: false property string lastError: "" + readonly property string uiFont: root.bar ? root.bar.fontFamily : Style.font.family + + // Each stage is its own file, and this panel cannot reach into one by id. So + // where an action has to touch a stage's field, it announces it and the + // stage does the rest (see the Connections in TierStage, NewPresetStage and + // RenameStage). + signal tierEditorOpened() + signal modelPicked(string modelId) + signal newPresetOpened() + signal renameOpened() function open() { root.expandedMode = "" @@ -385,18 +395,13 @@ Panel { root.lastError = "" root.editTier = tier root.editModel = "" - // Typing breaks the field's binding, so a value left from another tier - // would otherwise survive into this one. - modelField.text = "" - modelList.currentIndex = -1 root.stage = "presetTier" - Qt.callLater(function () { modelField.forceActiveFocus() }) + root.tierEditorOpened() } function pickModel(id) { root.editModel = id - modelField.text = id - modelField.forceActiveFocus() + root.modelPicked(id) } function saveTier() { @@ -506,9 +511,8 @@ Panel { var sibs = root.presetsOf(provider) root.newFrom = from !== null && from !== undefined ? from : (sibs.length > 0 ? String(sibs[0].name) : "") root.newName = root.uniqueName(from ? from + "-copy" : provider + "-new") - newNameField.text = root.newName root.stage = "presetNew" - Qt.callLater(function () { newNameField.forceActiveFocus(); newNameField.selectAll() }) + root.newPresetOpened() } function createPreset() { @@ -523,9 +527,8 @@ Panel { function openRename() { root.lastError = "" root.renameTo = root.editPreset - renameField.text = root.editPreset root.stage = "presetRename" - Qt.callLater(function () { renameField.forceActiveFocus(); renameField.selectAll() }) + root.renameOpened() } function renamePreset() { @@ -741,43 +744,6 @@ Panel { } - component PillButton: Rectangle { - id: pb - property string label: "" - property bool primary: false - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - font.bold: pb.primary - } - - MouseArea { - id: pbArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: pb.triggered() - } - } // KeyboardPanel, not PopupCard. PopupCard is an xdg-popup, which only gets // keys after focus is routed through its parent surface - so no text field @@ -834,7 +800,7 @@ Panel { visible: root.pLogo(root.mode) === "" text: root.pGlyph(root.mode) color: heroGlyph.tint - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.font.display } } @@ -849,7 +815,7 @@ Panel { elide: Text.ElideRight text: root.pTitle(root.mode) color: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.space(17) } @@ -858,7 +824,7 @@ Panel { elide: Text.ElideRight text: root.preset !== "" ? "preset · " + root.preset : root.pBlurb(root.mode) color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.space(11) } } @@ -866,1457 +832,20 @@ Panel { PanelSeparator { width: parent.width } - PanelSectionHeader { - text: "SWITCH TO" - visible: root.stage === "list" - foreground: Color.popups.text - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - } - - // ---- 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: root.stage === "list" - spacing: Style.space(1) - - Repeater { - model: root.stage === "list" ? root.modeOrder : [] - - Column { - id: modeEntry - required property var modelData - width: column.width - - readonly property string thisMode: String(modelData) - readonly property bool isCurrent: thisMode === root.mode - readonly property bool isExpanded: root.expandedMode === thisMode - readonly property var presetList: Modes.presetsFor(root.health, thisMode, root.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: root.pLogo(modeEntry.thisMode) - opticalScale: root.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: root.pTitle(modeEntry.thisMode) - color: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.body - font.bold: modeEntry.isCurrent - } - - Text { - width: parent.width - text: root.pBlurb(modeEntry.thisMode) - color: Color.muted - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.body - } - - MouseArea { - id: modeHover - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !root.busy - onClicked: root.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) === root.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: root.bar ? root.bar.fontFamily : Style.font.family - 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) === root.defaultPresets[modeEntry.thisMode] - anchors.left: presetName.right - anchors.leftMargin: Style.space(6) - anchors.verticalCenter: parent.verticalCenter - text: "default" - color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - 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: !root.busy - onClicked: root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(12) - } - - MouseArea { - id: gearArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !root.busy - onClicked: root.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 " + root.pTitle(modeEntry.thisMode) + " preset…" - color: newPresetArea.containsMouse ? Color.accent : Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - } - - MouseArea { - id: newPresetArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !root.busy - onClicked: root.openNewPreset(modeEntry.thisMode, null) - } - } - } - } - } - } - - // ---- Active model map, only meaningful behind a gateway. - - // ---- Preflight refused. Name the missing thing and offer the one action - // that fixes it, rather than failing the click silently. - Column { - width: parent.width - visible: root.stage === "blocked" && root.blocker !== null - spacing: Style.space(7) - - Text { - width: parent.width - text: root.blocker ? String(root.blocker.title) : "" - color: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - Text { - width: parent.width - text: root.blocker ? String(root.blocker.detail) : "" - color: Color.popups.text - wrapMode: Text.WordWrap - lineHeight: 1.2 - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - } - - Text { - width: parent.width - visible: root.blocker && String(root.blocker.remedy) !== "" - text: root.blocker ? "$ " + String(root.blocker.remedy) : "" - color: Color.muted - wrapMode: Text.WrapAnywhere - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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 { - label: "Set up " + root.pTitle(root.pendingMode) + "…" - primary: true - visible: root.blocker && String(root.blocker.remedyKind) === "setup" - onTriggered: root.runSetup() - } - PillButton { - label: "Store the key…" - primary: true - visible: root.blocker && String(root.blocker.remedyKind) === "set-key" - onTriggered: root.runRemedy() - } - // A preset with every tier empty: the fix is one click away here, - // not in a terminal. - PillButton { - label: "Edit preset…" - primary: true - visible: root.blocker && String(root.blocker.remedyKind) === "edit-preset" - onTriggered: { - var p = root.pendingPreset - root.resetFlow() - root.openPresetEditor(p) - } - } - PillButton { - label: "Server settings…" - primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0 - visible: root.serverEditable(root.pendingMode) - && !(root.blocker && String(root.blocker.remedyKind) === "setup") - onTriggered: root.openServerSettings(root.pendingPreset) - } - PillButton { - label: root.blocker && ["start-server", "setup"].indexOf(String(root.blocker.remedyKind)) >= 0 - ? "Check again" : "Try again" - primary: root.blocker && String(root.blocker.remedyKind) === "start-server" - onTriggered: root.retryPending() - } - PillButton { label: "Cancel"; onTriggered: root.resetFlow() } - } - } - - - // ---- Where a server provider is, and whether it needs a key. - Column { - width: parent.width - visible: root.stage === "server" - spacing: Style.space(8) - - Text { - width: parent.width - text: "Server for '" + root.serverPreset + "'" - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - Text { - width: parent.width - text: root.serverHint - color: Color.muted - wrapMode: Text.WordWrap - lineHeight: 1.2 - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - TextField { - id: urlField - width: parent.width - text: root.serverUrl - placeholderText: root.serverDefault !== "" ? root.serverDefault : "https://…" - foreground: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - onTextChanged: root.serverUrl = text - onAccepted: root.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 { - label: "Use local default" - visible: root.serverDefault !== "" - onTriggered: { root.serverUrl = root.serverDefault; urlField.text = root.serverDefault } - } - } - - PanelSeparator { width: parent.width } - - Row { - width: parent.width - spacing: Style.space(9) - - ToggleSwitch { - id: authToggle - anchors.verticalCenter: parent.verticalCenter - checked: root.serverNeedsKey - foreground: Color.popups.text - accent: Color.accent - onToggled: root.serverNeedsKey = !root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - } - - Text { - width: parent.width - text: root.serverNeedsKey - ? "Kept in the vault as '" + root.serverKeyRef + "', never in settings.json." - : "Sends " + root.pTitle(root.serverProvider) + "'s placeholder token, which is not a secret." - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.serverNeedsKey - - PillButton { label: "Store the key…"; onTriggered: root.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 { label: "Save"; primary: true; onTriggered: root.saveServerSettings() } - PillButton { - label: root.serverReturn !== "" ? "Back" : "Cancel" - onTriggered: { - if (root.serverReturn !== "") root.stage = root.serverReturn - else root.resetFlow() - } - } - } - } - - // ---- One preset: its tier map, each row opening a picker. - Column { - width: parent.width - visible: root.stage === "preset" && root.editPreset !== "" - spacing: Style.space(6) - - Text { - width: parent.width - text: "'" + root.editPreset + "' · " + root.pTitle(root.editProvider) - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - Text { - width: parent.width - visible: text !== "" - text: root.editEntry && root.editEntry.description ? String(root.editEntry.description) : "" - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - width: parent.width - visible: root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - width: parent.width - visible: root.editIsDefault - text: "claude-mode " + root.editProvider + " picks this one when no preset is named" - + (root.editIsChosenDefault ? "." : " (the built-in choice).") - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Column { - width: parent.width - spacing: Style.space(1) - - Repeater { - model: root.stage === "preset" ? root.tierNames : [] - - Item { - id: tierRow - required property var modelData - readonly property string tier: String(modelData) - readonly property string current: root.editCurrent(tier) - width: column.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: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.font.body - } - - MouseArea { - id: tierArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: !root.busy - onClicked: root.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 { - label: "Server settings…" - visible: root.serverEditable(root.editProvider) - onTriggered: root.openServerSettings(root.editPreset, "preset") - } - PillButton { label: "Make default"; visible: !root.editIsDefault; onTriggered: root.setDefault(true) } - PillButton { label: "Clear default"; visible: root.editIsChosenDefault; onTriggered: root.setDefault(false) } - PillButton { label: "Duplicate…"; onTriggered: root.openNewPreset(root.editProvider, root.editPreset) } - PillButton { label: "Rename…"; onTriggered: root.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 { - label: "Delete…" - enabled: !root.editIsActive - onTriggered: { root.lastError = ""; root.stage = "presetDelete" } - } - PillButton { label: "Done"; primary: true; onTriggered: root.resetFlow() } - } - - Text { - width: parent.width - visible: root.editIsActive - text: "In use, so it cannot be deleted. Switch to another preset first." - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - } - - // ---- New preset: a name, and what to start from. - Column { - width: parent.width - visible: root.stage === "presetNew" - spacing: Style.space(7) - - Text { - width: parent.width - text: "New " + root.pTitle(root.newProvider) + " preset" - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - TextField { - id: newNameField - width: parent.width - text: root.newName - placeholderText: "name" - foreground: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - onTextChanged: root.newName = text - onAccepted: root.createPreset() - } - - Text { - width: parent.width - readonly property string problem: root.stage === "presetNew" ? root.nameProblem(root.newName, "") : "" - visible: problem !== "" - text: problem - color: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - text: "Start from" - color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.stage === "presetNew" ? root.presetsOf(root.newProvider) : [] - - PillButton { - required property var modelData - label: "copy of " + String(modelData.name) - primary: root.newFrom === String(modelData.name) - onTriggered: root.newFrom = String(modelData.name) - } - } - - PillButton { - label: "blank" - primary: root.newFrom === "" - onTriggered: root.newFrom = "" - } - } - - Text { - id: newFromHint - width: parent.width - text: root.newFrom === "" - ? "Every tier starts empty; the editor opens next to fill them in." - : "Same server, key and models as '" + root.newFrom + "'. The editor opens next to change them." - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Flow { - width: parent.width - spacing: Style.space(7) - - PillButton { label: "Create"; primary: true; onTriggered: root.createPreset() } - PillButton { - label: "Cancel" - onTriggered: { - root.lastError = "" - if (root.editPreset !== "") root.stage = "preset" - else root.resetFlow() - } - } - } - } - - // ---- Rename: the file moves, and state.json follows if it is in use. - Column { - width: parent.width - visible: root.stage === "presetRename" - spacing: Style.space(7) - - Text { - width: parent.width - text: "Rename '" + root.editPreset + "'" - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - TextField { - id: renameField - width: parent.width - text: root.renameTo - placeholderText: "new name" - foreground: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - onTextChanged: root.renameTo = text - onAccepted: root.renamePreset() - } - - Text { - width: parent.width - readonly property string problem: root.stage === "presetRename" ? root.nameProblem(root.renameTo, root.editPreset) : "" - visible: problem !== "" - text: problem - color: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - width: parent.width - visible: root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Flow { - width: parent.width - spacing: Style.space(7) - - PillButton { label: "Rename"; primary: true; onTriggered: root.renamePreset() } - PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } } - } - } - - // ---- Delete: confirmed, and says so when it leaves a provider empty. - Column { - width: parent.width - visible: root.stage === "presetDelete" - spacing: Style.space(7) - - readonly property bool lastOne: root.stage === "presetDelete" - && root.presetsOf(root.editProvider).length <= 1 - - Text { - width: parent.width - text: "Delete '" + root.editPreset + "'?" - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - width: parent.width - visible: parent.lastOne - text: "It is the only " + root.pTitle(root.editProvider) + " preset, so " - + root.pTitle(root.editProvider) + " will have nothing to switch to until you create another." - color: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Flow { - width: parent.width - spacing: Style.space(7) - - PillButton { label: "Delete it"; primary: true; onTriggered: root.deletePreset() } - PillButton { label: "Back"; onTriggered: { root.lastError = ""; root.stage = "preset" } } - } - } - - // ---- One tier: pick from the cached catalogue, or type any id. - Column { - width: parent.width - visible: root.stage === "presetTier" - spacing: Style.space(7) - - Text { - width: parent.width - text: root.editTier + " · '" + root.editPreset + "'" - color: Color.popups.text - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - Text { - width: parent.width - text: "now: " + (root.editCurrent(root.editTier) || "not set") - color: Color.muted - elide: Text.ElideLeft - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.editModel - placeholderText: root.modelOptions.length > 0 - ? "Search " + root.modelOptions.length + " models, or type any id" - : "Model id" - foreground: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - onTextChanged: { - root.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 < root.modelMatches.length) - root.pickModel(String(root.modelMatches[modelList.currentIndex].value)) - else - root.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: root.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: root.stage === "presetTier" ? root.modelMatches : [] - - delegate: Item { - id: matchRow - required property var modelData - required property int index - readonly property bool chosen: String(modelData.value) === root.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: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - } - - MouseArea { - id: matchArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - } - - Text { - width: parent.width - visible: root.modelOptions.length === 0 - text: root.catalogueNode && root.catalogueNode.ok === false - ? "The last fetch failed and no list is cached. Type an id, or try fetching again." - : "No model list cached for " + root.pTitle(root.editProvider) + " yet. Type an id, or fetch the list." - color: Color.muted - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - width: parent.width - visible: root.catalogueNode !== null && root.modelOptions.length > 0 - text: root.catalogueNode - ? root.modelOptions.length + " models cached, fetched " + root.ageLabel(root.catalogueNode.fetchedAt) - + (root.catalogueNode.ok === false ? " · the last refresh failed" : "") - : "" - color: Color.muted - opacity: 0.85 - elide: Text.ElideRight - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - - Flow { - width: parent.width - spacing: Style.space(7) - - PillButton { label: "Save"; primary: true; onTriggered: root.saveTier() } - PillButton { - label: root.modelOptions.length > 0 ? "Refresh list" : "Fetch models" - onTriggered: root.fetchModels() - } - PillButton { - label: "Back" - onTriggered: { root.lastError = ""; root.stage = "preset" } - } - } - } - - // ---- 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. - Column { - width: parent.width - visible: root.stage === "confirm" && root.sessionInfo !== null - spacing: Style.space(7) - - readonly property int total: root.sessionInfo ? Number(root.sessionInfo.count) : 0 - readonly property int busyCount: root.sessionInfo ? Number(root.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: root.bar ? root.bar.fontFamily : Style.font.family - 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 " + root.pTitle(root.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: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Column { - width: parent.width - spacing: Style.space(1) - - Repeater { - model: root.sessionInfo ? root.sessionInfo.sessions : [] - - Item { - required property var modelData - width: column.width - height: Style.space(17) - - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: parent.modelData.tty - color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - id: busyTag - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - visible: parent.modelData.busy === true - text: "working" - color: root.urgentColor - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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 { - label: "Switch and restart" - primary: true - onTriggered: root.applySwitch("restart") - } - PillButton { label: "Switch and close"; onTriggered: root.applySwitch("stop") } - PillButton { label: "Switch only"; onTriggered: root.applySwitch("none") } - PillButton { label: "Cancel"; onTriggered: root.resetFlow() } - } - } - - - // ---- Sessions that a switch cut short. Shown in the list, because the - // point is to be noticed without going looking. - Column { - width: parent.width - visible: root.stage === "list" && (root.broken.length > 0 || root.ignored.length > 0) - spacing: Style.space(4) - - PanelSeparator { width: parent.width } - - Text { - width: parent.width - visible: root.broken.length > 0 - text: root.broken.length === 1 - ? "1 session cannot be resumed" - : root.broken.length + " sessions cannot be resumed" - color: root.urgentColor - wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - font.bold: true - } - - Repeater { - model: root.stage === "list" ? root.broken : [] - - Item { - id: brokenRow - required property var modelData - width: column.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) - + " · " + root.projectLabel(brokenRow.modelData.project) - color: Color.popups.text - elide: Text.ElideMiddle - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - } - - PillButton { - id: ignoreBtn - anchors.right: repairBtn.left - anchors.rightMargin: Style.space(5) - anchors.verticalCenter: parent.verticalCenter - label: "Ignore" - onTriggered: root.setIgnored(brokenRow.modelData, true) - } - - PillButton { - id: repairBtn - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - label: "Repair" - onTriggered: root.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: root.ignored.length > 0 - - Text { - id: hiddenLabel - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "hidden (" + root.ignored.length + ")" - color: hiddenArea.containsMouse ? Color.popups.text : Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Text { - anchors.left: hiddenLabel.right - anchors.leftMargin: Style.space(4) - anchors.verticalCenter: parent.verticalCenter - text: String.fromCodePoint(root.hiddenExpanded ? 0xF0140 : 0xF0142) - color: hiddenLabel.color - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - } - - MouseArea { - id: hiddenArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.hiddenExpanded = !root.hiddenExpanded - } - } - - Column { - width: parent.width - visible: root.hiddenExpanded && root.ignored.length > 0 - spacing: Style.space(3) - - Text { - visible: root.dismissedList.length > 0 - text: "Ignored (" + root.dismissedList.length + ")" - color: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - font.bold: true - } - - Repeater { - model: root.hiddenExpanded ? root.dismissedList.slice(0, root.hiddenCap) : [] - - Item { - id: dismissedRow - required property var modelData - width: column.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) - + " · " + root.projectLabel(dismissedRow.modelData.project) - color: Color.muted - elide: Text.ElideMiddle - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - PillButton { - id: restoreBtn - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - label: "Restore" - onTriggered: root.setIgnored(dismissedRow.modelData, false) - } - } - } - - Text { - visible: root.dismissedList.length > root.hiddenCap - text: "… and " + (root.dismissedList.length - root.hiddenCap) - + " more · claude-mode repair-session --ignored" - color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - - Text { - visible: root.staleList.length > 0 - text: "Older than " + root.ageDays + " days (" + root.staleList.length + ")" - color: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - font.bold: true - } - - Repeater { - model: root.hiddenExpanded ? root.staleList.slice(0, root.hiddenCap) : [] - - Text { - required property var modelData - width: column.width - text: String(modelData.sessionId).substring(0, 8) - + " · " + Math.floor((Date.now() / 1000 - Number(modelData.mtime)) / 86400) + "d" - + " · " + root.projectLabel(modelData.project) - color: Color.muted - elide: Text.ElideMiddle - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - } - - Text { - width: parent.width - visible: root.staleList.length > 0 - text: (root.staleList.length > root.hiddenCap - ? "… and " + (root.staleList.length - root.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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(9) - } - } - } - - // ---- Confirming one repair. - Column { - width: parent.width - visible: root.stage === "repair" && root.repairTarget !== null - spacing: Style.space(7) - - Text { - width: parent.width - text: "Repair " + (root.repairTarget ? String(root.repairTarget.sessionId).substring(0, 8) : "") - color: Color.popups.text - font.family: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(13) - font.bold: true - } - - Text { - width: parent.width - text: { - if (!root.repairTarget) return "" - var p = (root.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 " - + root.repairTarget.dropLines + " lines." - } - color: Color.muted - wrapMode: Text.WordWrap - lineHeight: 1.2 - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(10) - } - - Flow { - width: parent.width - spacing: Style.space(7) - - PillButton { label: "Repair it"; primary: true; onTriggered: root.doRepair() } - PillButton { label: "Cancel"; onTriggered: root.resetFlow() } - } - } - - PanelSeparator { width: parent.width; visible: root.modelRows.length > 0 && root.stage === "list" } - - Column { - width: parent.width - spacing: Style.space(1) - visible: root.modelRows.length > 0 && root.stage === "list" - - PanelSectionHeader { - text: "MODEL MAP" - foreground: Color.popups.text - fontFamily: root.bar ? root.bar.fontFamily : Style.font.family - } - - Repeater { - model: root.modelRows - - Item { - required property var modelData - width: column.width - height: Style.space(19) - - Text { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: parent.modelData.tier - color: Color.muted - font.family: root.bar ? root.bar.fontFamily : Style.font.family - 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: root.bar ? root.bar.fontFamily : Style.font.family - font.pixelSize: Style.space(11) - } - } - } - } + // The stages, in the order they stack. Each is its own file drawing from + // this panel's state, and shows only when it is its turn. + ModeList { panel: root } + BlockedStage { panel: root } + ServerStage { panel: root } + PresetStage { panel: root } + NewPresetStage { panel: root } + RenameStage { panel: root } + DeleteStage { panel: root } + TierStage { panel: root } + SessionsStage { panel: root } + BrokenSessions { panel: root } + RepairStage { panel: root } + ModelMap { panel: root } PanelSeparator { width: parent.width } @@ -2328,7 +857,7 @@ Panel { text: root.lastError color: root.urgentColor wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.space(10) } @@ -2341,7 +870,7 @@ Panel { visible: text !== "" color: Color.muted wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.space(10) } @@ -2352,7 +881,7 @@ Panel { color: Color.muted opacity: 0.8 wrapMode: Text.WordWrap - font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.family: root.uiFont font.pixelSize: Style.space(10) } } diff --git a/omarchy/smoido.claude-mode/PillButton.qml b/omarchy/smoido.claude-mode/PillButton.qml new file mode 100644 index 0000000..6a9c79f --- /dev/null +++ b/omarchy/smoido.claude-mode/PillButton.qml @@ -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() + } +} diff --git a/omarchy/smoido.claude-mode/PresetStage.qml b/omarchy/smoido.claude-mode/PresetStage.qml new file mode 100644 index 0000000..497d793 --- /dev/null +++ b/omarchy/smoido.claude-mode/PresetStage.qml @@ -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) + } +} diff --git a/omarchy/smoido.claude-mode/RenameStage.qml b/omarchy/smoido.claude-mode/RenameStage.qml new file mode 100644 index 0000000..c1ec369 --- /dev/null +++ b/omarchy/smoido.claude-mode/RenameStage.qml @@ -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() }) + } + } +} diff --git a/omarchy/smoido.claude-mode/RepairStage.qml b/omarchy/smoido.claude-mode/RepairStage.qml new file mode 100644 index 0000000..32a4a8f --- /dev/null +++ b/omarchy/smoido.claude-mode/RepairStage.qml @@ -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() } + } +} diff --git a/omarchy/smoido.claude-mode/ServerStage.qml b/omarchy/smoido.claude-mode/ServerStage.qml new file mode 100644 index 0000000..0586391 --- /dev/null +++ b/omarchy/smoido.claude-mode/ServerStage.qml @@ -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() + } + } + } +} diff --git a/omarchy/smoido.claude-mode/SessionsStage.qml b/omarchy/smoido.claude-mode/SessionsStage.qml new file mode 100644 index 0000000..22cd890 --- /dev/null +++ b/omarchy/smoido.claude-mode/SessionsStage.qml @@ -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() } + } +} diff --git a/omarchy/smoido.claude-mode/TierStage.qml b/omarchy/smoido.claude-mode/TierStage.qml new file mode 100644 index 0000000..3ec127e --- /dev/null +++ b/omarchy/smoido.claude-mode/TierStage.qml @@ -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() + } + } +} diff --git a/tests/static.sh b/tests/static.sh index fb9bcdc..d598b6f 100755 --- a/tests/static.sh +++ b/tests/static.sh @@ -160,10 +160,12 @@ QMLLINT="$(command -v qmllint || true)" if [ -n "$QMLLINT" ]; then # The shell's own modules (qs.*, Quickshell) do not resolve outside it, so # 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() { local f out 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; } done }