From f68ca080099b505ba53459b1489e366889046671 Mon Sep 17 00:00:00 2001 From: smoido Date: Sun, 30 Aug 2026 21:07:03 +0300 Subject: [PATCH] Omarchy bar widget: show the active provider, and switch from it A Quickshell plugin for the Omarchy shell (v4's bar is Quickshell, not Waybar). The icon is the mode; clicking it opens a panel that switches. State comes from watching health.json rather than polling the CLI, so the widget costs nothing while idle and a switch made in a terminal shows up in the bar on its own. state.json is the fallback, because it exists from install onward where health.json only appears once claude-mode has run. The marks are the providers' real logos, drawn as vector paths through QtQuick.Shapes rather than set as font glyphs - three of the four have no Nerd Font pictograph at all, and paths take the bar's foreground colour and follow the theme. Claude, OpenRouter and LM Studio from simple-icons, Z.AI from lobe-icons; trademarks belong to their owners. Zhipu's mark was rejected for Z.AI: it is a dense lattice that is unreadable below ~40px. Rendering them at 13px took three fixes. A layer rasterises the Shape at its own size and then scales the texture, so a 24px buffer minified to 13 resampled two pixels into one and the Claude burst lost rays; without the layer the scale is a transform on the geometry and rasterisation happens once, at final resolution. CurveRenderer replaces the tessellating default. And the size is forced odd, because a radially symmetric mark puts its vertical and horizontal arms on the centre line, which is a pixel centre at odd sizes and the seam between two pixels at even ones, where each arm splits its coverage and greys out. Measured against a cairo render at the same size, the result is now identical. Sizing is measured rather than guessed. Every stock glyph in this bar paints 11px of ink; the marks fill their box instead of carrying a font's padding, so the box is the smaller number. Each mark also carries an optical scale from two measurements of a 200px render - LM Studio's filled container covers 69% of its box against ~38% for the others, and Z.AI and OpenRouter are wide-but-short marks spanning ~84% of the box height. Choosing a target does not switch immediately. It runs the CLI's own preflight, and if that refuses, the panel names what is missing and offers the fix - a terminal for the hidden key prompt, a re-check once a server is up, or the server form. If sessions are running it lists them by terminal and directory, marks any mid-request, and asks whether to restart them, close them, or leave them. The session action is applied strictly after the write, since restarting first would only bring them back up on the provider just left. The server form edits an LM Studio preset's base URL and whether it needs an API key, reachable from the gear on any LM Studio preset row - per row, because two presets can point at two different machines - and from the failure card. The shipped local default is untouched unless it is changed. --- .gitignore | 3 + README.md | 276 +++++- omarchy/install.sh | 91 ++ omarchy/smoido.claude-mode/BarWidget.qml | 257 ++++++ omarchy/smoido.claude-mode/BrandIcon.qml | 62 ++ omarchy/smoido.claude-mode/Modes.js | 160 ++++ omarchy/smoido.claude-mode/Panel.qml | 1008 ++++++++++++++++++++++ omarchy/smoido.claude-mode/manifest.json | 18 + 8 files changed, 1870 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100755 omarchy/install.sh create mode 100644 omarchy/smoido.claude-mode/BarWidget.qml create mode 100644 omarchy/smoido.claude-mode/BrandIcon.qml create mode 100644 omarchy/smoido.claude-mode/Modes.js create mode 100644 omarchy/smoido.claude-mode/Panel.qml create mode 100644 omarchy/smoido.claude-mode/manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07ee00b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.bak +*.bak.* diff --git a/README.md b/README.md index ce79f83..97226fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ Switch Claude Code system-wide between **Anthropic**, **OpenRouter**, **Z.AI**, and a local **LM Studio** server — with named per-tier model presets. -Windows / PowerShell 5.1, no external dependencies. + +Windows / PowerShell 5.1 with no external dependencies, and a POSIX port for +Linux and macOS that needs only python3 — including a theme-aware TUI and an +Omarchy bar widget. See [Linux / Omarchy](#linux--omarchy). ``` claude-mode # interactive menu @@ -110,6 +113,13 @@ claude-mode preset rm claude-mode set-key [ref] store a key (hidden prompt, DPAPI) claude-mode models [filter] models available from the active provider claude-mode doctor verify auth, endpoint, model ids, stray env vars + +claude-mode preflight [preset] can this mode actually serve? (no switch) +claude-mode sessions running sessions, and which are mid-request +claude-mode sessions --stop close them (asks first) +claude-mode sessions --restart close and reopen each in its own directory +claude-mode sessions --dry-run show what either would do, and do nothing +claude-mode --force switch even when preflight says no ``` Omitting the preset uses a **fixed** per-provider default, not "most recently @@ -119,9 +129,8 @@ used" — so `claude-mode openrouter` always means `default`. | preset | provider | opus | sonnet | haiku | fable | |---|---|---|---|---|---| -| `default` | openrouter | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openrouter/free` | `z-ai/glm-5.2` | -| `cheap` | openrouter | `deepseek/deepseek-v4-pro` | `deepseek/deepseek-v4-flash` | `qwen/qwen3.7-flash` | `openai/gpt-5.6-luna-pro` | -| `claude-via-or` | openrouter | `anthropic/claude-opus-5` | `anthropic/claude-sonnet-5` | `anthropic/claude-haiku-4.5` | `anthropic/claude-fable-5` | +| `default` | openrouter | `deepseek/deepseek-v4-flash-0731` | `qwen/qwen3.7-flash` | `openrouter/free` | `z-ai/glm-5.2` | +| `cheap` | openrouter | `deepseek/deepseek-v4-pro` | `deepseek/deepseek-v4-flash-0731` | `qwen/qwen3.7-flash` | `openai/gpt-5.6-luna-pro` | | `zai` | zai | `glm-5.2` | `glm-5.2` | `glm-4.7` | `glm-5.2` | | `lmstudio` | lmstudio | `kwaipilot_kat-coder-v2.5-dev` (all tiers) | | | | | `lmstudio-qwen` | lmstudio | `qwen3.6-35b-a3b-uncensored-heretic-native-mtp-preserved` (all tiers) | | | | @@ -130,6 +139,10 @@ Presets are plain JSON in `~/.claude-mode/presets/`. A preset declares its `provider`; `claude-mode lmstudio default` is rejected rather than silently pointing a local URL at remote model IDs. +To route the real Anthropic models through OpenRouter, copy one and repoint the +tiers - `claude-mode preset new claude-via-or default`, then `preset set` each +tier to `anthropic/claude-opus-5` and friends. + ## Context windows and early auto-compaction **Symptom:** switch to a gateway and the session starts auto-compacting almost @@ -151,7 +164,7 @@ CLAUDE_CODE_AUTO_COMPACT_WINDOW = | preset | contextTokens | |---|---| -| `default`, `cheap`, `claude-via-or`, `zai` | 1,000,000 | +| `default`, `cheap`, `zai` | 1,000,000 | | `lmstudio`, `lmstudio-qwen` | 262,144 | `doctor` cross-checks the declared window against each tier's *actual* model @@ -316,6 +329,259 @@ can change. `extraEnv` key (Z.AI's timeouts, LM Studio's attribution header) is removed when you switch away — even though no other preset knows that key exists. +## Linux / Omarchy + +The `linux/` tree is a POSIX port of the same design: one write to +`~/.claude/settings.json`, secrets kept out of it, presets shared verbatim with +the Windows build. + +```bash +cd claude-code-switcher +bash linux/install.sh # --skip-key-prompt to install without storing a key +``` + +Installs to `~/.claude-mode/`, symlinks `~/.local/bin/claude-mode`, and adds the +`claude` wrapper to `~/.bashrc` / `~/.zshrc` between markers. + +Secrets have no DPAPI equivalent here, so the vault picks the best backend +available and says which one it chose: macOS Keychain, libsecret, `pass`, or a +0600 file that is honestly labelled as unencrypted. + +### The menu follows your desktop theme + +The sixteen ANSI colour slots carry no guarantee about relative brightness, and +monochrome themes exploit that. Under Omarchy's Solitude, slot 36 — headings — +resolves to `#707070` and slot 31 — `FAIL` — to `#565d60`. Against `#cacccc` +body text on a `#101315` ground that is 3.8:1 and 2.8:1 where the body text is +11.6:1, so headings render as fine print and an error becomes the quietest +thing on screen. Exactly backwards. + +So when Omarchy is present, the palette is derived from the theme it publishes +at `~/.local/state/omarchy/current/theme/colors.toml` instead. Every role is +measured against the background it will actually be drawn on and lifted toward +the foreground when it falls short, which keeps hue where the theme has any and +falls back to weight where it does not: + +| role | before (Solitude) | after | +|---|---|---| +| heading | 3.8:1 | 9.4:1 | +| `FAIL` | 2.8:1 | 5.2:1 | +| help text | 2.2:1 | 2.2:1 (recessive on purpose, floored) | + +Light themes are handled by the same arithmetic — `mode` in `colors.toml` is +authoritative, so `catppuccin-latte` and `flexoki-light` keep their accents +rather than washing out. + +Overrides: `CLAUDE_MODE_THEME=/path/to/colors.toml` points it elsewhere, +`NO_COLOR` turns it off. Without Omarchy, or on a terminal that cannot do +truecolor, it falls back to the ANSI slots with the two roles the slots get +wrong corrected — bright red for `FAIL`, bold on headings. + +### `claude-mode health` + +Rewrites `~/.claude-mode/health.json`, the machine-readable mirror of the +active configuration: mode, preset, model map, context window, key backend, and +the switchable preset catalogue. No key material. A switch and a `status` both +refresh it; the command exists for anything that wants to force it. + +## Refusing a switch that would not work + +A switch writes settings.json and is picked up by the *next* `claude` launch, so +switching into a mode that cannot serve requests does not fail loudly — it +succeeds, and every session started afterwards is broken in a way that points at +Claude Code rather than at here. + +LM Studio is the sharp case. Its token is an inline placeholder, so nothing +about the switch needs the server to exist; point at a server that is not +running and you get a config that looks perfectly healthy and answers nothing. + +So the preconditions are checked before the write, not after: + +| mode | checked | when it fails | +|---|---|---| +| `anthropic` | nothing to check | — | +| `openrouter`, `zai` | a key exists for the preset's `keyRef`, and the helper is executable | `claude-mode set-key ` | +| `lmstudio` (anywhere) | the server answers, and accepts the credential the preset would send | see below | +| any preset on a local base URL | same probe | start the server | +| all | the preset exists and declares the provider being switched to | — | + +The probe distinguishes four outcomes, because their remedies are opposites: + +| result | means | remedy offered | +|---|---|---| +| `ok` | answered `/api/v0/models` or `/v1/models` | — | +| `auth` | the server is up and refused the credential | store or fix the key, or turn auth on for the preset | +| `notfound` | something is listening, but the API is not at that path | fix the base URL | +| `refused` | nothing answered at all — down, asleep, DNS, TLS, timeout | start the server, or fix the address | + +`claude-mode preflight [preset]` runs exactly these and prints the +verdict as JSON without switching; it is what the bar widget calls before it +offers to do anything. `--force` overrides the lot. + +Remote *gateways* are not probed — OpenRouter or Z.AI being briefly unreachable +is the network's problem and not worth blocking a config change over, where a +missing key never fixes itself. LM Studio is probed wherever it lives, because +an instance on a sleeping LAN box is exactly as absent as a loopback port with +nothing behind it, and produces the identical silent breakage. Off-machine +addresses get a longer timeout, not a pass. + +### LM Studio somewhere other than this machine + +It ships on loopback, but that is a default, not a constraint. A preset is just +a base URL and an auth block, so all of these are the same two fields: + +```bash +claude-mode preset url lmstudio http://192.168.1.40:1234 # another box on the LAN +claude-mode preset url lmstudio https://lms.example.net # through a tunnel or proxy +claude-mode preset auth lmstudio key lmstudio # that server wants an API key +claude-mode set-key lmstudio # store it (vault, not settings.json) +claude-mode preset auth lmstudio none # back to the open-server default +``` + +`auth none` writes LM Studio's inline placeholder token, which is not a secret +and is what an unauthenticated server expects. `auth key` moves it to the vault +like every other credential — a real key on a public address is a real key. +Nothing here changes the shipped presets unless you ask it to; the local default +stays exactly as it was. + +## Sessions still on the old provider + +Claude Code reads all of this once, at startup. A switch therefore does nothing +to a session already running: it keeps talking to the old provider on the old +key until it is restarted, which is the confusing part — the bar says one thing +and the session in front of you is doing another. A session mid-request is worse +off, because anthropic mode *deletes* the helper, so an in-flight turn can fail +on its next tool call rather than stopping cleanly. + +``` +claude-mode sessions + running sessions (2) + 562250 pts/4 /home/smoido/Work working (this session - never touched) + 631644 pts/1 /home/smoido/Projects/api +``` + +Sessions are found through `/proc//exe`, which on Linux resolves to the +real `claude` binary — a process-name match would sweep up every shell that +merely mentions claude on its command line, including the one this is running +from. Two things are then filtered out: + +- **The calling session.** Killing the session that asked for the kill is not a + thing anyone means, so it is listed and never signalled. +- **Forks of a session.** A busy session spawns children off its own binary, + and they inherit the same `exe`. Without excluding anything whose parent is + itself claude, the count climbs and falls with how hard the machine is + thinking — it read 2, 5, 11 and 40 on the same two sessions before this. A + real session's parent is a terminal. + +`working` is a sampled-CPU heuristic — two reads of `utime + stime` 300ms apart +— so it is a good guess about which session is mid-turn, not a promise. + +`--stop` sends SIGTERM (never SIGKILL; Claude Code writes out its transcript on +the way down). `--restart` stops each session and reopens it — re-running the +parent terminal's own command line where there is one, so the same terminal, +flags and directory come back, and falling back to a fresh terminal in the +session's directory otherwise. Interactive runs confirm first; `--yes` is for +callers that have already asked, and `--dry-run` prints the plan and touches +nothing. + +## The Omarchy bar widget + +An icon in the Omarchy top bar showing which provider the next `claude` launch +will use, and a panel that switches it without a terminal. + +```bash +bash omarchy/install.sh +``` + +Copies the plugin to `~/.config/omarchy/plugins/smoido.claude-mode/` and adds +its id to the bar layout in `~/.config/omarchy/shell.json` (backed up first). +Both hot-reload, so nothing needs restarting — except after a change to +`Modes.js`, which the QML engine caches for the life of the process as a +`.pragma library` (`omarchy restart shell`). + +The icon is the mode, and it is the provider's own logo: the Claude burst, the +OpenRouter arrow, the Z.AI Z, the LM Studio mark. They are drawn as vector paths +with `QtQuick.Shapes` rather than set as font glyphs — three of the four have no +Nerd Font pictograph at all, and being paths means they take the bar's +foreground colour and follow the theme like everything else. Anything other than +Anthropic takes the theme accent, so the bar stays quiet exactly when nothing +unusual is configured. + +Marks come from [simple-icons](https://simpleicons.org) (Claude, OpenRouter, LM +Studio) and [lobe-icons](https://github.com/lobehub/lobe-icons) (Z.AI); +trademarks belong to their owners. Each carries an optical scale factor, because +equal nominal size is not equal apparent size — LM Studio's filled container +covers 69% of its box against ~38% for the other three, and Z.AI and OpenRouter +are wide-but-short marks whose ink spans only ~84% of the box height. The +factors in `Modes.js` are the geometric mean of both corrections. + +Three details keep them from looking ragged in a 13px slot, which is what every +stock glyph in this bar measures: + +- **No layer.** A layer rasterises the Shape at its own size and then scales the + *texture*, so a 24px buffer minified to 13 resamples ~2 pixels into 1. Without + one the scale is a transform on the geometry and rasterisation happens once, + at final resolution. +- **`Shape.CurveRenderer`** (Qt 6.6+) rasterises curves analytically instead of + tessellating them into antialiased triangles. Measured against a cairo render + of the same mark at the same size, the two now come out identical. +- **An odd `iconSize`.** These marks are radially symmetric, so their vertical + and horizontal arms sit on the centre line — which is a pixel *centre* at an + odd size and the seam between two pixels at an even one, where each arm splits + its coverage and greys out. Even values are rounded up. + +- **Click** the icon for the panel: current mode, every mode with the active one + ticked, and the model map behind a gateway. Picking a gateway unfolds its + presets rather than switching blind; picking a preset starts the switch. + +Choosing a target does not switch immediately — it runs the same two checks the +CLI does, and both can stop it: + +1. **Preflight.** If the mode has no key stored, or its server is not answering, + the panel says which and offers the fix: *Store the key…* opens a terminal + for the hidden prompt (a bar popup has nowhere to host one), *Check again* + re-runs the preflight once the server is up, and *Server settings…* opens the + form below. The gear on any LM Studio preset row opens the same form without + waiting for a failure — per row, because two LM Studio presets can point at + two different machines. + + The form holds the base URL, a *Use local default* reset, and a switch for + whether that server needs an API key. Saving rewrites the preset and drops + straight back into the switch that was blocked. +2. **Running sessions.** If any are running, the panel lists them by terminal + and directory, marks any that are mid-request, and asks: *Switch and restart*, + *Switch and close*, *Switch only*, or *Cancel*. The session action is applied + strictly **after** the write — restarting first would only bring them back up + on the provider you just left. +- **Right-click** switches straight back to Anthropic. +- **Middle-click** re-reads state. +- **Hover** for mode, preset, and the opus/sonnet mapping. + +State comes from watching `health.json`, not from polling the CLI, so the +widget costs nothing while idle and a switch made in a terminal shows up in the +bar on its own. A failed switch — a preset whose key was never stored is the +common one — surfaces the CLI's own error in the panel rather than looking +like a click that did nothing. + +Per-instance settings in the `shell.json` layout entry: + +| key | default | meaning | +|---|---|---| +| `showLabel` | `false` | show the preset name beside the icon as well | +| `iconSize` | `13` | mark size in px; rounded up to odd (see below) | +| `root` | `~/.claude-mode` | where claude-mode is installed | + +Placement is `right`, before `omarchy.agents`; override with `CM_BAR_SECTION` +and `CM_BAR_BEFORE` when installing. Moving it later is a normal +`omarchy bar move smoido.claude-mode --section
`. + +Uninstall: + +```bash +rm -rf ~/.config/omarchy/plugins/smoido.claude-mode +# then remove the {"id": "smoido.claude-mode"} entry from ~/.config/omarchy/shell.json +``` + ## Uninstall ```powershell diff --git a/omarchy/install.sh b/omarchy/install.sh new file mode 100755 index 0000000..0177d00 --- /dev/null +++ b/omarchy/install.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Installs the claude-mode bar widget into the Omarchy shell. +# +# Two steps: drop the plugin into ~/.config/omarchy/plugins/, and add its id to +# the bar layout in ~/.config/omarchy/shell.json. Both are idempotent, and the +# shell hot-reloads each of them, so nothing has to be restarted. +# +# Only ~/.config is touched. /usr/share/omarchy is owned by the package and is +# rewritten by `omarchy update`, so nothing may be installed there. + +set -euo pipefail + +PLUGIN_ID="smoido.claude-mode" +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$PLUGIN_ID" +DEST_DIR="$HOME/.config/omarchy/plugins/$PLUGIN_ID" +SHELL_JSON="$HOME/.config/omarchy/shell.json" + +# Where in the bar the widget lands, and which existing widget it sits before. +# The AI-adjacent group on the right is the natural neighbourhood for it. +SECTION="${CM_BAR_SECTION:-right}" +BEFORE="${CM_BAR_BEFORE:-omarchy.agents}" + +green() { printf ' \033[32mok \033[0m %s\n' "$*"; } +warn() { printf ' \033[33mwarn\033[0m %s\n' "$*"; } +fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; } + +printf '\ninstalling %s -> %s\n' "$PLUGIN_ID" "$DEST_DIR" + +[ -d "$SRC" ] || { fail "plugin source not found: $SRC"; exit 1; } +command -v python3 >/dev/null 2>&1 || { fail 'python3 required'; exit 1; } + +if [ ! -d "$HOME/.config/omarchy" ]; then + fail 'no ~/.config/omarchy - this does not look like an Omarchy system' + exit 1 +fi + +# --- payload --------------------------------------------------------------- +mkdir -p "$DEST_DIR" +for f in manifest.json BarWidget.qml Panel.qml BrandIcon.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' + +# --- bar layout ------------------------------------------------------------ +# shell.json is the user's own file and may carry unrelated customisation, so +# it is read, minimally amended, and written back rather than templated over. +if [ ! -f "$SHELL_JSON" ]; then + warn "no $SHELL_JSON - add the widget yourself once the shell writes one" + exit 0 +fi + +cp "$SHELL_JSON" "$SHELL_JSON.bak.$(date +%s)" + +python3 - "$SHELL_JSON" "$PLUGIN_ID" "$SECTION" "$BEFORE" <<'PYEOF' +import json, sys + +path, plugin_id, section, before = sys.argv[1:5] +with open(path, encoding="utf-8") as fh: + cfg = json.load(fh) + +layout = cfg.setdefault("bar", {}).setdefault("layout", {}) +entries = layout.setdefault(section, []) + +def has(entry_list): + return any(isinstance(e, dict) and e.get("id") == plugin_id for e in entry_list) + +# Already placed anywhere in the bar: leave the user's chosen position alone. +for name, items in layout.items(): + if isinstance(items, list) and has(items): + print(" already in bar layout (%s) - position left as-is" % name) + raise SystemExit(0) + +idx = len(entries) +for i, e in enumerate(entries): + if isinstance(e, dict) and e.get("id") == before: + idx = i + break + +entries.insert(idx, {"id": plugin_id}) + +with open(path, "w", encoding="utf-8") as fh: + json.dump(cfg, fh, indent=2) + fh.write("\n") +print(" added to bar.layout.%s at position %d" % (section, idx)) +PYEOF + +green 'shell.json updated (hot-reloads; no restart needed)' + +printf '\ndone. The icon shows the active mode; click it to switch.\n' +printf 'If it does not appear: omarchy-shell shell rescanPlugins\n' diff --git a/omarchy/smoido.claude-mode/BarWidget.qml b/omarchy/smoido.claude-mode/BarWidget.qml new file mode 100644 index 0000000..6d182d1 --- /dev/null +++ b/omarchy/smoido.claude-mode/BarWidget.qml @@ -0,0 +1,257 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Modes.js" as Modes + +// Bar slot for claude-mode: shows which provider the next `claude` launch will +// use, and opens a panel that switches it. +// +// State comes from ~/.claude-mode/health.json, which claude-mode rewrites on +// every switch and on every `status`. Watching that file rather than polling +// the CLI keeps this widget at zero cost while idle, and means a switch made +// in a terminal shows up here without anything having to tell us. +BarWidget { + id: root + moduleName: "smoido.claude-mode" + + readonly property string cmRoot: String(setting("root", Modes.defaultRoot())) + // Off by default - the bar is scarce horizontal space and the mark already + // says which provider is active. Turn it on per-instance in shell.json to get + // the preset name back beside the icon. + readonly property bool showLabel: setting("showLabel", false) === true + + // Whole health.json, or null before the first successful read. + property var health: null + // state.json is the narrower fallback: it exists from install onward, where + // health.json only appears once claude-mode has run at least once. Without + // this the widget would render "unknown" on a fresh install until the first + // switch, which is precisely when someone is most likely to look at it. + property var fallbackState: null + + readonly property string mode: { + if (health && health.mode) return String(health.mode) + if (fallbackState && fallbackState.mode) return String(fallbackState.mode) + return "" + } + readonly property string preset: { + if (mode === "anthropic") return "" + if (health && health.preset) return String(health.preset) + if (fallbackState && fallbackState.preset) return String(fallbackState.preset) + return "" + } + readonly property bool known: mode !== "" + + // Measured against the neighbours: every stock bar glyph in this shell paints + // 11px of ink from a 13px font. These marks fill their box rather than + // carrying a font's internal padding, so the box itself has to be the smaller + // number or the widget sits visibly larger than everything beside it. + // Odd on purpose. The marks are radially symmetric, so their vertical and + // horizontal arms sit on the centre line - which lands on a pixel *centre* at + // an odd size and on the boundary between two pixels at an even one, where + // each arm splits its coverage and comes out grey on both sides. + readonly property int iconPx: { + var n = Math.round(Number(root.setting("iconSize", Style.bar.iconFont))) + return n % 2 === 0 ? n + 1 : n + } + readonly property string logo: Modes.logo(mode) + readonly property string glyph: Modes.glyph(mode) + readonly property string label: Modes.shortLabel(mode, preset) + + // A gateway mode is spending money or leaning on a local server; native + // Anthropic is the resting state. Only the former earns the accent, so the + // bar stays quiet exactly when nothing unusual is configured. + readonly property bool gateway: known && mode !== "anthropic" + + readonly property color activeColor: !known + ? Color.muted + : (gateway ? Color.accent : (bar ? bar.barForeground : Color.foreground)) + + // The bar's shared tooltip only paints for a target that reports itself + // hovered through this exact property. + readonly property bool tooltipHovered: visible && opacity > 0 && hoverHandler.hovered + + function parseInto(prop, content) { + try { + var parsed = JSON.parse(String(content || "")) + root[prop] = (parsed && typeof parsed === "object") ? parsed : null + } catch (e) { + root[prop] = null + } + } + + // `text()` is stale inside onFileChanged, so both first load and every later + // change are routed through reload() -> onLoaded to always parse fresh bytes. + FileView { + id: healthFile + path: root.cmRoot + "/health.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseInto("health", text()) + onLoadFailed: root.health = null + } + + FileView { + id: stateFile + path: root.cmRoot + "/state.json" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.parseInto("fallbackState", text()) + onLoadFailed: root.fallbackState = null + } + + // claude-mode writes health.json itself, so a switch launched from the panel + // lands back here through the FileView above. This only covers the case + // where the file was never written at all. + Process { + id: seedProc + command: [root.cmRoot + "/bin/claude-mode", "health"] + running: false + } + + function refresh() { + healthFile.reload() + stateFile.reload() + if (!root.health) seedProc.running = true + } + + Component.onCompleted: Qt.callLater(root.refresh) + + // ---- Bar content + + readonly property int gap: Style.space(5) + + readonly property real iconWidth: root.logo !== "" ? icon.implicitWidth : fallbackIcon.implicitWidth + + implicitWidth: vertical + ? barSize + : (iconWidth + (labelText.visible ? gap + labelText.implicitWidth : 0) + Style.space(10)) + implicitHeight: vertical ? (iconPx + Style.space(10)) : barSize + + Row { + anchors.centerIn: parent + spacing: root.gap + + BrandIcon { + id: icon + anchors.verticalCenter: parent.verticalCenter + visible: root.logo !== "" + pathData: root.logo + opticalScale: Modes.logoScale(root.mode) + color: root.activeColor + iconSize: root.iconPx + } + + // Only reached when claude-mode is not installed - there is no brand to + // draw for "no answer", so the pictograph stands in. + Text { + id: fallbackIcon + anchors.verticalCenter: parent.verticalCenter + visible: root.logo === "" + text: root.glyph + color: root.activeColor + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.bar.iconFont + renderType: Text.NativeRendering + } + + Text { + id: labelText + anchors.verticalCenter: parent.verticalCenter + visible: !root.vertical && root.showLabel && root.label !== "" + text: root.label + color: root.activeColor + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.bar.iconFont - 1 + renderType: Text.NativeRendering + } + } + + // ---- Interaction + + // The bar decides a module is clickable - and gives it the pointing-hand + // cursor - purely by whether it exposes triggerPress(). + function triggerPress(button) { + if (root.bar) root.bar.hideTooltip(root) + root.togglePanel() + } + + function tooltipLabel() { + if (!root.known) return "claude-mode: not installed" + var lines = [Modes.title(root.mode)] + if (root.preset !== "") lines.push("preset " + root.preset) + var models = root.health && root.health.models ? root.health.models : null + if (models && models.opus) lines.push("opus " + models.opus) + if (models && models.sonnet) lines.push("sonnet " + models.sonnet) + return lines.join(" · ") + } + + function syncTooltip() { + if (!bar || !hoverHandler.hovered) return + var text = tooltipLabel() + if (bar.tooltipTarget === root) { + if (bar.tooltipText !== text) bar.tooltipText = text + } else { + bar.showTooltip(root, text) + } + } + + onHealthChanged: syncTooltip() + + // Hover must come from a HoverHandler: once triggerPress() exists the bar's + // own slot MouseArea accepts hover events and swallows them before any + // MouseArea here would see them. The call is deferred one turn because + // `hovered` flips before tooltipHovered has propagated, and showTooltip() + // silently refuses a target that does not yet report itself hovered. + HoverHandler { + id: hoverHandler + onHoveredChanged: { + if (!root.bar) return + if (hovered) Qt.callLater(root.syncTooltip) + else root.bar.hideTooltip(root) + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton | Qt.MiddleButton + onClicked: function(mouse) { + if (mouse.button === Qt.MiddleButton) root.refresh() + else if (panelLoader.item) panelLoader.item.switchTo("anthropic", "") + } + } + + // ---- Panel wiring + + function injectPanel() { + var target = panelLoader.item + if (!target) return + if ("bar" in target) target.bar = root.bar + if ("settings" in target) target.settings = root.settings + if ("anchorItem" in target) target.anchorItem = root + if ("hostWidget" in target) target.hostWidget = root + if ("widget" in target) target.widget = root + } + + function togglePanel() { if (panelLoader.item) panelLoader.item.toggle() } + + readonly property bool opened: panelLoader.item ? panelLoader.item.opened === true : false + function open() { if (panelLoader.item) panelLoader.item.open() } + function close() { if (panelLoader.item) panelLoader.item.close() } + + onBarChanged: injectPanel() + onSettingsChanged: injectPanel() + + Loader { + id: panelLoader + active: true + source: Qt.resolvedUrl("Panel.qml") + visible: false + onLoaded: { + root.injectPanel() + Qt.callLater(root.injectPanel) + } + } +} diff --git a/omarchy/smoido.claude-mode/BrandIcon.qml b/omarchy/smoido.claude-mode/BrandIcon.qml new file mode 100644 index 0000000..c3741e5 --- /dev/null +++ b/omarchy/smoido.claude-mode/BrandIcon.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Shapes +import qs.Commons + +// One provider logo, drawn as a vector path and filled with whatever colour it +// is handed - which is what lets these follow the theme the way a font glyph +// would, instead of being a fixed-colour bitmap that looks pasted on. +// +// Every mark is authored in a 24x24 viewBox (see Modes.js) and scaled to the +// requested size from there. +// +// Two things about how that scaling is done, both of which matter at bar sizes: +// +// 1. No `layer.enabled`. A layer renders the Shape into a texture at the item's +// own size and then scales the *texture*, so a 24x24 buffer minified to 16px +// resamples 1.5 pixels into 1. On the Claude burst, whose rays are under a +// pixel wide at that size, that is not softening - rays merge, drop out, and +// come back at uneven weights as the widget moves. Without the layer the +// scale is a transform on the geometry instead, so rasterisation happens +// once, at final resolution. +// +// 2. CurveRenderer rather than the default geometry renderer. It rasterises +// curves analytically with its own antialiasing instead of tessellating them +// into AA'd triangles, which is what keeps sub-pixel detail smooth rather +// than stair-stepped. Qt 6.6+; this ships against 6.11. +Item { + id: root + + property string pathData: "" + property real iconSize: Style.font.icon + property color color: Color.foreground + // Per-mark optical correction; see LOGO_SCALE in Modes.js for why equal + // nominal size is not equal apparent size. + property real opticalScale: 1.0 + + // Rounded so the mark is laid out on whole pixels. Thin strokes straddling a + // pixel boundary lose half their coverage to each side and read as grey. + readonly property int box: Math.round(iconSize) + + implicitWidth: box + implicitHeight: box + visible: pathData !== "" + + Shape { + id: shape + width: 24 + height: 24 + anchors.centerIn: parent + antialiasing: true + preferredRendererType: Shape.CurveRenderer + scale: root.box * root.opticalScale / 24 + + ShapePath { + // No fillRule set on purpose: these marks are authored for nonzero + // winding, which is the default, and LM Studio's bars are knocked out + // of its container by winding direction alone. + fillColor: root.color + strokeWidth: 0 + PathSvg { path: root.pathData } + } + } +} diff --git a/omarchy/smoido.claude-mode/Modes.js b/omarchy/smoido.claude-mode/Modes.js new file mode 100644 index 0000000..281fe06 --- /dev/null +++ b/omarchy/smoido.claude-mode/Modes.js @@ -0,0 +1,160 @@ +.pragma library + +// The four modes claude-mode understands, in the order the CLI menu lists +// them. Kept here rather than in either QML file because the bar widget needs +// the glyph and the panel needs the prose, and a second copy of this table is +// exactly the sort of thing that drifts. + +// Material Design icons from the Nerd Font patch set, written as codepoints +// rather than literals so they survive any editor or transport that is not +// UTF-8 clean. All verified present in JetBrainsMono Nerd Font, the shell's +// default family. +// +// Deliberately none of them a robot: omarchy.agents already paints one, and it +// commonly sits in the same stretch of bar, where two identical robots read as +// one widget drawn twice. Same reason lmstudio is a server rack rather than a +// screen - omarchy.monitor owns that silhouette. +var GLYPH = { + anthropic: String.fromCodePoint(0xF0674), // sparkles - the real thing + openrouter: String.fromCodePoint(0xF0469), // router - remote gateway + zai: String.fromCodePoint(0xF015F), // cloud - hosted GLM plan + lmstudio: String.fromCodePoint(0xF048B), // server - local LM Studio + unknown: String.fromCodePoint(0xF0625) // question - nothing readable +} + +// --------------------------------------------------------------------------- +// Brand marks +// +// The real logo for each provider, as SVG path data in a 24x24 viewBox so the +// whole set scales from a single number. Drawn with QtQuick.Shapes rather than +// set as font glyphs: three of the four have no Nerd Font pictograph at all, +// and a stand-in pictograph is both less recognisable and impossible to keep +// visually consistent with the marks beside it. +// +// Sources: Claude, OpenRouter and LM Studio from simple-icons; Z.AI from +// lobe-icons. All four are monochrome single-path marks, which is what lets +// them take the bar's foreground colour and follow the theme. Trademarks +// belong to their respective owners. +// +// LM Studio's mark keeps its rounded-square container: the four bars alone +// read as a hamburger menu, and the container is what makes it that logo. It +// relies on nonzero winding to knock the bars out, which is QtQuick.Shapes' +// default, so no fill rule is set anywhere here. +var LOGO = { + anthropic: "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z", + openrouter: "M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z", + zai: "M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z", + lmstudio: "M14.025 0c3.492 0 5.237 0 6.571.68a6.24 6.24 0 0 1 2.725 2.724C24 4.738 24 6.484 24 9.975v4.05c0 3.492 0 5.237-.68 6.571a6.24 6.24 0 0 1-2.724 2.725c-1.334.679-3.08.679-6.571.679h-4.05c-3.492 0-5.237 0-6.571-.68A6.24 6.24 0 0 1 .68 20.597C0 19.262 0 17.516 0 14.025v-4.05c0-3.492 0-5.237.68-6.571A6.23 6.23 0 0 1 3.404.68C4.738 0 6.484 0 9.975 0zM7.688 16.313a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625zm-3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm3-3.75a1.313 1.313 0 0 0 0 2.624h11.625a1.313 1.313 0 0 0 0-2.624zm-3-3.75a1.313 1.313 0 0 0 0 2.625h11.625a1.313 1.313 0 0 0 0-2.625z" +} + +// Equal nominal size is not equal apparent size, so each mark is corrected by +// two measurements taken off a 200px render of it: +// +// bbox Z.AI and OpenRouter are wide-but-short marks - their ink spans only +// ~84% of the box height, so at equal nominal size they read smaller. +// ink LM Studio's filled container covers 69% of its box against ~38% for +// the other three, so at equal nominal size it reads much heavier. +// +// The correction is the geometric mean of both signals, which pulls the short +// marks up and the solid one down without letting either measure dominate. +var LOGO_SCALE = { + anthropic: 0.99, + openrouter: 1.12, + zai: 1.07, + lmstudio: 0.86 +} + +function logo(mode) { + return LOGO[mode] || "" +} + +function logoScale(mode) { + return LOGO_SCALE[mode] || 1.0 +} + +var TITLE = { + anthropic: "Anthropic", + openrouter: "OpenRouter", + zai: "Z.AI", + lmstudio: "LM Studio" +} + +var BLURB = { + anthropic: "Your subscription login. No gateway, no API key.", + openrouter: "Remote gateway, pay per token, any vendor.", + zai: "GLM coding plan on Z.AI's Anthropic endpoint.", + lmstudio: "Local LM Studio server. Offline and free." +} + +var ORDER = ["anthropic", "openrouter", "zai", "lmstudio"] + +// Everything except anthropic switches with a named preset. +function needsPreset(mode) { + return mode !== "" && mode !== "anthropic" +} + +function glyph(mode) { + return GLYPH[mode] || GLYPH.unknown +} + +function title(mode) { + return TITLE[mode] || "not installed" +} + +function blurb(mode) { + return BLURB[mode] || "" +} + +// Bar label. The preset matters more than the provider once you are on a +// gateway - "openrouter" tells you nothing you did not already know from the +// icon, where "cheap" is the thing you actually want to catch sight of. +function shortLabel(mode, preset) { + if (mode === "") return "" + if (mode === "anthropic") return "anthropic" + return preset !== "" ? preset : mode +} + +function defaultRoot() { + return homeDir() + "/.claude-mode" +} + +function homeDir() { + // Qt exposes no home path to a .pragma library, and the shell always runs as + // the owning user, so derive it the one way that holds in both places. + var url = Qt.resolvedUrl(".").toString() + var m = url.match(/^file:\/\/(\/home\/[^\/]+)\//) + if (m) return m[1] + m = url.match(/^file:\/\/(\/Users\/[^\/]+)\//) + return m ? m[1] : "/root" +} + +// Presets from health.json that belong to one provider, default first so the +// list opens on the one `claude-mode ` would have picked. +function presetsFor(health, mode, defaults) { + var all = (health && health.presets) ? health.presets : [] + var out = [] + for (var i = 0; i < all.length; i++) { + if (String(all[i].provider) === mode) out.push(all[i]) + } + var preferred = defaults[mode] + out.sort(function (a, b) { + if (a.name === preferred) return -1 + if (b.name === preferred) return 1 + return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0) + }) + return out +} + +// Mirrors default_preset_for() in the CLI. +var DEFAULT_PRESET = { + openrouter: "default", + zai: "zai", + lmstudio: "lmstudio" +} + +function contextLabel(tokens) { + if (!tokens) return "" + if (tokens >= 1000000) return (Math.round(tokens / 100000) / 10) + "M context" + if (tokens >= 1000) return Math.round(tokens / 1024) + "k context" + return tokens + " context" +} diff --git a/omarchy/smoido.claude-mode/Panel.qml b/omarchy/smoido.claude-mode/Panel.qml new file mode 100644 index 0000000..27d5f08 --- /dev/null +++ b/omarchy/smoido.claude-mode/Panel.qml @@ -0,0 +1,1008 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui +import "Modes.js" as Modes + +Panel { + id: root + moduleName: "smoido.claude-mode" + ipcTarget: "smoido.claude-mode" + + property var anchorItem: null + property var hostWidget: null + property var widget: null + + // The bar identifies a panel by the widget mounted in its slot, not by this + // nested panel, so the popout coordinator has to be handed that widget. + readonly property var barIdentity: hostWidget || root + + readonly property var health: widget ? widget.health : null + readonly property string mode: widget ? widget.mode : "" + readonly property string preset: widget ? widget.preset : "" + readonly property bool known: widget ? widget.known : false + readonly property string cmRoot: widget ? widget.cmRoot : Modes.defaultRoot() + + // Which gateway row has its preset list unfolded. Only one at a time, and + // never anthropic, which takes no preset. + property string expandedMode: "" + property bool busy: false + property string lastError: "" + + function open() { + root.expandedMode = "" + root.lastError = "" + root.resetFlow() + if (widget) widget.refresh() + root.controller.show() + } + function toggle() { root.opened ? root.close() : root.open() } + + // A gateway mode needs a preset chosen before anything can be applied, so + // clicking one unfolds its list instead of switching blind. anthropic has + // nothing to choose and applies on the spot. + function activate(mode) { + if (!Modes.needsPreset(mode)) { switchTo(mode, ""); return } + root.expandedMode = (root.expandedMode === mode) ? "" : mode + } + + // ---- Switch flow + // + // A switch is not one action. It rewrites settings.json, which every future + // session reads and no running session re-reads, so two things have to be + // settled before the write: whether the target can actually serve requests, + // and what should happen to the sessions still talking to the old one. + // + // list -> preflight -> blocked + // -> sessions -> confirm -> switch -> list + // + // Both questions are answered by claude-mode rather than re-derived here, so + // the widget and the CLI can never disagree about whether a switch is allowed + // or about what is running. + property string stage: "list" // list | blocked | confirm + property var blocker: null // the preflight verdict, when it refused + property var sessionInfo: null // sessions --json, when any were found + property string pendingMode: "" + property string pendingPreset: "" + + function cli(args) { return [root.cmRoot + "/bin/claude-mode"].concat(args) } + function parseJson(t) { try { return JSON.parse(String(t || "")) } catch (e) { return null } } + + // ---- Server settings (LM Studio) + // + // LM Studio ships listening on loopback, but it does not have to be there: it + // can be another machine on the LAN, or something reached through a tunnel or + // a reverse proxy, and it can have authentication switched on. All three are + // just a baseUrl and an auth block in the preset, so this edits those two + // rather than pretending the local default is the only shape. + property string serverPreset: "" + property string serverUrl: "" + property bool serverNeedsKey: false + property string serverKeyRef: "lmstudio" + readonly property string serverLocalDefault: "http://127.0.0.1:1234" + + function presetEntry(name) { + var all = (health && health.presets) ? health.presets : [] + for (var i = 0; i < all.length; i++) if (String(all[i].name) === name) return all[i] + return null + } + + function openServerSettings(presetName) { + var e = root.presetEntry(presetName) + root.serverPreset = presetName + root.serverUrl = e && e.baseUrl ? String(e.baseUrl) : root.serverLocalDefault + root.serverNeedsKey = !!(e && String(e.authMode) === "vault") + root.serverKeyRef = e && e.keyRef ? String(e.keyRef) : "lmstudio" + root.blocker = null + root.stage = "server" + } + + // Two writes, sequenced: the URL first, then the auth block, then health.json + // is refreshed so the form and the rest of the panel agree about what the + // preset now says. + function saveServerSettings() { + if (root.busy || root.serverPreset === "") return + root.busy = true + urlProc.command = root.cli(["preset", "url", root.serverPreset, root.serverUrl.trim()]) + urlProc.running = true + } + + Process { + id: urlProc + running: false + onExited: function (code) { + authProc.command = root.cli(root.serverNeedsKey + ? ["preset", "auth", root.serverPreset, "key", root.serverKeyRef] + : ["preset", "auth", root.serverPreset, "none"]) + authProc.running = true + } + } + + Process { + id: authProc + running: false + onExited: function (code) { + healthProc.command = root.cli(["health"]) + healthProc.running = true + } + } + + Process { + id: healthProc + running: false + onExited: function (code) { + root.busy = false + if (widget) widget.refresh() + // Straight back into the switch that was blocked, if there was one - + // the whole point of coming here was to unblock it. + if (root.pendingMode !== "") root.retryPending() + else root.stage = "list" + } + } + + function storeServerKey() { + remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"]) + remedyProc.running = true + } + + function resetFlow() { + root.stage = "list" + root.blocker = null + root.sessionInfo = null + root.pendingMode = "" + root.pendingPreset = "" + root.serverPreset = "" + } + + function switchTo(mode, presetName) { + if (root.busy) return + root.lastError = "" + root.pendingMode = mode + root.pendingPreset = presetName + root.busy = true + preflightProc.command = root.cli(presetName === "" + ? ["preflight", mode] : ["preflight", mode, presetName]) + preflightProc.running = true + } + + // Nothing is written until this says so. A mode with no key stored, or a + // local server that is not running, would otherwise switch cleanly and leave + // every session started afterwards broken in a way that looks like Claude + // Code's fault rather than a missing key. + Process { + id: preflightProc + running: false + property var verdict: null + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: preflightProc.verdict = root.parseJson(text) + } + onExited: function (code) { + var v = preflightProc.verdict + preflightProc.verdict = null + if (v && v.ok === false) { + root.busy = false + root.blocker = v + root.stage = "blocked" + return + } + sessionsProc.command = root.cli(["sessions", "--json"]) + sessionsProc.running = true + } + } + + Process { + id: sessionsProc + running: false + property var info: null + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: sessionsProc.info = root.parseJson(text) + } + onExited: function (code) { + var info = sessionsProc.info + sessionsProc.info = null + root.busy = false + if (info && info.count > 0) { + root.sessionInfo = info + root.stage = "confirm" + return + } + root.applySwitch("none") + } + } + + // sessionAction: none | stop | restart, applied to the sessions left on the + // old provider once the new one is written. + function applySwitch(sessionAction) { + root.busy = true + root.stage = "list" + switchProc.sessionAction = sessionAction + switchProc.command = root.cli(root.pendingPreset === "" + ? [root.pendingMode] : [root.pendingMode, root.pendingPreset]) + switchProc.running = true + } + + // claude-mode rewrites health.json as the last step of a switch, so the + // widget's FileView is what actually updates the display. This only has to + // report failures and then deal with the sessions. + Process { + id: switchProc + running: false + property string sessionAction: "none" + stderr: StdioCollector { + waitForEnd: true + onStreamFinished: { + var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim() + var lines = msg.split("\n").filter(function (l) { return l.trim() !== "" }) + if (lines.length > 0) root.lastError = lines[lines.length - 1].replace(/^\s*(FAIL|warn)\s*/, "").trim() + } + } + onExited: function (code) { + root.busy = false + root.expandedMode = "" + root.blocker = null + root.sessionInfo = null + if (code === 0) { + root.lastError = "" + // Strictly after the write. A session reopened before it would come + // straight back up on the provider we just left. + if (switchProc.sessionAction !== "none") { + sessionActProc.command = root.cli(["sessions", + switchProc.sessionAction === "restart" ? "--restart" : "--stop", "--yes"]) + sessionActProc.running = true + } + } + switchProc.sessionAction = "none" + root.pendingMode = "" + root.pendingPreset = "" + if (widget) widget.refresh() + } + } + + // --yes because the confirmation already happened, in the card below. Without + // it the CLI would refuse rather than act on a decision nobody typed. + Process { id: sessionActProc; running: false } + + // The remedy for a missing key is a hidden password prompt, which a bar popup + // has nowhere to host - so the CLI is asked to open a terminal for it. + Process { id: remedyProc; running: false } + + function runRemedy() { + var b = root.blocker + if (!b) return + if (String(b.remedyKind) === "set-key") { + remedyProc.command = root.cli(["set-key", String(b.keyRef), "--terminal"]) + remedyProc.running = true + } + } + + function retryPending() { + if (root.pendingMode === "") { root.resetFlow(); return } + var m = root.pendingMode, p = root.pendingPreset + root.blocker = null + root.stage = "list" + root.switchTo(m, p) + } + + readonly property var modelRows: { + var models = health && health.models ? health.models : null + if (!models) return [] + var tiers = ["opus", "sonnet", "haiku", "fable"] + var out = [] + for (var i = 0; i < tiers.length; i++) { + if (models[tiers[i]]) out.push({ tier: tiers[i], id: String(models[tiers[i]]) }) + } + return out + } + + // Color.urgent derives from the theme's `red`, and monochrome themes define + // that as a desaturated slate - Solitude's is #565d60, dimmer than the panel + // text it is supposed to outrank, so an error would read as the quietest + // line on the card. Use the theme's colour only when it carries real hue. + readonly property color urgentColor: { + var c = Color.urgent + var mx = Math.max(c.r, c.g, c.b) + var mn = Math.min(c.r, c.g, c.b) + var lightness = (mx + mn) / 2 + var denom = 1 - Math.abs(2 * lightness - 1) + var saturation = denom > 0.0001 ? (mx - mn) / denom : 0 + return saturation >= 0.15 ? c : "#d2685f" + } + + readonly property string footerText: { + if (!known) return "claude-mode not found in " + cmRoot + var bits = [] + if (health && health.version) bits.push("claude-mode " + health.version) + if (health && health.keyBackend) bits.push("key: " + health.keyBackend) + var ctx = health && health.contextTokens ? Modes.contextLabel(health.contextTokens) : "" + if (ctx !== "") bits.push(ctx) + return bits.join(" · ") + } + + + 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) + + 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() + } + } + + PopupCard { + id: card + anchorItem: root.anchorItem + owner: root.barIdentity + bar: root.bar + open: root.opened + contentWidth: card.fittedContentWidth(Style.space(360)) + contentHeight: card.fittedContentHeight(column.implicitHeight) + + Column { + id: column + width: card.contentWidth - card.padding * 2 + spacing: Style.space(10) + + // ---- Hero: what the next `claude` will actually use. + Row { + id: heroRow + width: parent.width + spacing: Style.space(10) + + Item { + id: heroGlyph + anchors.verticalCenter: parent.verticalCenter + width: Style.font.display + height: Style.font.display + + readonly property color tint: root.known && root.mode !== "anthropic" + ? Color.accent : Color.popups.text + + BrandIcon { + anchors.centerIn: parent + visible: Modes.logo(root.mode) !== "" + pathData: Modes.logo(root.mode) + opticalScale: Modes.logoScale(root.mode) + color: heroGlyph.tint + iconSize: Style.font.display + } + + Text { + anchors.centerIn: parent + visible: Modes.logo(root.mode) === "" + text: Modes.glyph(root.mode) + color: heroGlyph.tint + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.font.display + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: heroRow.width - heroGlyph.width - heroRow.spacing + spacing: Style.space(2) + + Text { + width: parent.width + elide: Text.ElideRight + text: Modes.title(root.mode) + color: Color.popups.text + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(17) + } + + Text { + width: parent.width + elide: Text.ElideRight + text: root.preset !== "" ? "preset · " + root.preset : Modes.blurb(root.mode) + color: Color.muted + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(11) + } + } + } + + 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" ? Modes.ORDER : [] + + 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, Modes.DEFAULT_PRESET) + + 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: Modes.logo(modeEntry.thisMode) + opticalScale: Modes.logoScale(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: Modes.title(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: Modes.blurb(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. + Column { + width: parent.width + visible: modeEntry.isExpanded && modeEntry.presetList.length > 0 + 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 + } + + // 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: 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 edits where that preset + // points instead. Per row rather than per provider, because + // 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: modeEntry.thisMode === "lmstudio" + 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.openServerSettings(String(presetRow.modelData.name)) + } + } + } + } + } + } + } + } + + // ---- 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) + } + + Row { + spacing: Style.space(7) + + PillButton { + label: "Store the key…" + primary: true + visible: root.blocker && String(root.blocker.remedyKind) === "set-key" + onTriggered: root.runRemedy() + } + PillButton { + label: "Server settings…" + primary: root.blocker && ["start-server", "set-url", "needs-key"].indexOf(String(root.blocker.remedyKind)) >= 0 + visible: root.pendingMode === "lmstudio" + onTriggered: root.openServerSettings(root.pendingPreset) + } + PillButton { + label: root.blocker && String(root.blocker.remedyKind) === "start-server" + ? "Check again" : "Try again" + primary: root.blocker && String(root.blocker.remedyKind) === "start-server" + onTriggered: root.retryPending() + } + PillButton { label: "Cancel"; onTriggered: root.resetFlow() } + } + } + + + // ---- Where the LM Studio server 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: "LM Studio does not have to be on this machine. Point this at a " + + "LAN address, or anything reachable through a tunnel or proxy." + 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.serverLocalDefault + 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() + } + + Row { + spacing: Style.space(7) + + PillButton { + label: "Use local default" + onTriggered: { root.serverUrl = root.serverLocalDefault; urlField.text = root.serverLocalDefault } + } + } + + 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 LM Studio'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) + } + } + } + + Row { + spacing: Style.space(7) + visible: root.serverNeedsKey + + PillButton { label: "Store the key…"; onTriggered: root.storeServerKey() } + } + + PanelSeparator { width: parent.width } + + Row { + spacing: Style.space(7) + + PillButton { label: "Save"; primary: true; onTriggered: root.saveServerSettings() } + PillButton { label: "Cancel"; onTriggered: root.resetFlow() } + } + } + + // ---- 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 + } + + Text { + width: parent.width + text: "Claude Code reads its settings once, at startup, so these keep " + + Modes.title(root.mode) + " until they are restarted." + 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 lose that turn." + : parent.busyCount + " are working right now and will lose those turns." + 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) + } + } + } + } + + Row { + spacing: Style.space(7) + + 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() } + } + } + + 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) + } + } + } + } + + PanelSeparator { width: parent.width } + + // ---- Failures worth surfacing: a preset with no key stored exits + // non-zero, and without this the click would just look inert. + Text { + width: parent.width + visible: root.lastError !== "" + text: root.lastError + 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 + text: root.busy + ? "switching…" + : (root.known && root.stage === "list" ? "Restart claude to pick this up." : "") + visible: text !== "" + 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 + text: root.footerText + visible: text !== "" + color: Color.muted + opacity: 0.8 + wrapMode: Text.WordWrap + font.family: root.bar ? root.bar.fontFamily : Style.font.family + font.pixelSize: Style.space(10) + } + } + } +} diff --git a/omarchy/smoido.claude-mode/manifest.json b/omarchy/smoido.claude-mode/manifest.json new file mode 100644 index 0000000..a048b23 --- /dev/null +++ b/omarchy/smoido.claude-mode/manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "smoido.claude-mode", + "name": "Claude Mode", + "version": "1.8.0", + "author": "smoido", + "description": "Which provider Claude Code is pointed at, and a one-click switch between them", + "kinds": ["bar-widget"], + "entryPoints": { + "barWidget": "BarWidget.qml" + }, + "barWidget": { + "displayName": "Claude Mode", + "description": "Active Claude Code provider and preset, with in-place switching", + "category": "Info", + "allowMultiple": false + } +}