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 + } +}