From 0a234ce41d2a0b2efc96dafb79d2dccded47f8af Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 22 Sep 2026 21:47:29 +0800 Subject: [PATCH] dial: one dial for every coding agent's model Wails v3 tray panel and window, TUI and CLI. Switches model, effort and provider for Claude Code, Codex, Gemini CLI, OpenCode, Pi, Goose, Cursor, Copilot CLI and Crush with format-preserving config edits. Providers page and registry, stored keys, live vendor model lists, profiles. Co-Authored-By: Claude --- .gitignore | 4 + Makefile | 60 ++ README.md | 184 +++++ build/darwin/Info.plist | 18 + build/darwin/dial.icns | Bin 0 -> 102870 bytes build/icon/gen.go | 151 ++++ go.mod | 43 ++ go.sum | 87 +++ gui_off.go | 9 + gui_on.go | 9 + internal/agent/agent.go | 144 ++++ internal/agent/agents.go | 255 +++++++ internal/agent/claude.go | 172 +++++ internal/agent/codex.go | 328 +++++++++ internal/agent/codex_prompt.md | 80 +++ internal/agent/gemini.go | 119 ++++ internal/agent/providers.go | 655 ++++++++++++++++++ internal/catalog/catalog.go | 326 +++++++++ internal/catalog/live.go | 185 +++++ internal/edit/del_test.go | 82 +++ internal/edit/edit_test.go | 190 +++++ internal/edit/env.go | 75 ++ internal/edit/file.go | 58 ++ internal/edit/json.go | 287 ++++++++ internal/edit/text.go | 192 +++++ internal/edit/toml.go | 167 +++++ internal/edit/toml_test.go | 86 +++ internal/gui/api.go | 203 ++++++ internal/gui/app.go | 131 ++++ internal/gui/assets/app.css | 303 ++++++++ internal/gui/assets/app.js | 631 +++++++++++++++++ internal/gui/assets/icons/claude-color.svg | 1 + .../gui/assets/icons/claudecode-color.svg | 1 + internal/gui/assets/icons/codex-color.svg | 1 + internal/gui/assets/icons/crush.png | Bin 0 -> 16519 bytes internal/gui/assets/icons/cursor.svg | 1 + internal/gui/assets/icons/deepseek-color.svg | 1 + internal/gui/assets/icons/gemini-color.svg | 1 + internal/gui/assets/icons/geminicli-color.svg | 1 + internal/gui/assets/icons/githubcopilot.svg | 1 + .../gui/assets/icons/googlecloud-color.svg | 1 + internal/gui/assets/icons/goose.svg | 1 + internal/gui/assets/icons/kimi.svg | 1 + internal/gui/assets/icons/minimax-color.svg | 1 + internal/gui/assets/icons/openai.svg | 1 + internal/gui/assets/icons/opencode.svg | 1 + internal/gui/assets/icons/openrouter.svg | 1 + internal/gui/assets/icons/pi.svg | 1 + internal/gui/assets/icons/qwen-color.svg | 1 + internal/gui/assets/icons/zai.svg | 1 + internal/gui/assets/icons/zhipu-color.svg | 1 + internal/gui/assets/index.html | 67 ++ internal/gui/icon.png | Bin 0 -> 1670 bytes internal/gui/providers.go | 183 +++++ internal/gui/tray.png | Bin 0 -> 482 bytes internal/profile/profile.go | 158 +++++ internal/tui/tui.go | 648 +++++++++++++++++ main.go | 315 +++++++++ providers_cli.go | 319 +++++++++ 59 files changed, 6943 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 build/darwin/Info.plist create mode 100644 build/darwin/dial.icns create mode 100644 build/icon/gen.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gui_off.go create mode 100644 gui_on.go create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agents.go create mode 100644 internal/agent/claude.go create mode 100644 internal/agent/codex.go create mode 100644 internal/agent/codex_prompt.md create mode 100644 internal/agent/gemini.go create mode 100644 internal/agent/providers.go create mode 100644 internal/catalog/catalog.go create mode 100644 internal/catalog/live.go create mode 100644 internal/edit/del_test.go create mode 100644 internal/edit/edit_test.go create mode 100644 internal/edit/env.go create mode 100644 internal/edit/file.go create mode 100644 internal/edit/json.go create mode 100644 internal/edit/text.go create mode 100644 internal/edit/toml.go create mode 100644 internal/edit/toml_test.go create mode 100644 internal/gui/api.go create mode 100644 internal/gui/app.go create mode 100644 internal/gui/assets/app.css create mode 100644 internal/gui/assets/app.js create mode 100644 internal/gui/assets/icons/claude-color.svg create mode 100644 internal/gui/assets/icons/claudecode-color.svg create mode 100644 internal/gui/assets/icons/codex-color.svg create mode 100644 internal/gui/assets/icons/crush.png create mode 100644 internal/gui/assets/icons/cursor.svg create mode 100644 internal/gui/assets/icons/deepseek-color.svg create mode 100644 internal/gui/assets/icons/gemini-color.svg create mode 100644 internal/gui/assets/icons/geminicli-color.svg create mode 100644 internal/gui/assets/icons/githubcopilot.svg create mode 100644 internal/gui/assets/icons/googlecloud-color.svg create mode 100644 internal/gui/assets/icons/goose.svg create mode 100644 internal/gui/assets/icons/kimi.svg create mode 100644 internal/gui/assets/icons/minimax-color.svg create mode 100644 internal/gui/assets/icons/openai.svg create mode 100644 internal/gui/assets/icons/opencode.svg create mode 100644 internal/gui/assets/icons/openrouter.svg create mode 100644 internal/gui/assets/icons/pi.svg create mode 100644 internal/gui/assets/icons/qwen-color.svg create mode 100644 internal/gui/assets/icons/zai.svg create mode 100644 internal/gui/assets/icons/zhipu-color.svg create mode 100644 internal/gui/assets/index.html create mode 100644 internal/gui/icon.png create mode 100644 internal/gui/providers.go create mode 100644 internal/gui/tray.png create mode 100644 internal/profile/profile.go create mode 100644 internal/tui/tui.go create mode 100644 main.go create mode 100644 providers_cli.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2abcde6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dial +/dial.exe +/dial.app/ +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0d91495b --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS = -s -w -X main.version=$(VERSION) +TAGS = production +TARGETS = darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64 windows/arm64 + +# The GUI links the platform webview through cgo, so it is built natively. +# `nogui` builds the terminal-only dial, which cross-compiles anywhere. +ifeq ($(shell uname -s),Darwin) + export CGO_CFLAGS = -mmacosx-version-min=11.0 + export CGO_LDFLAGS = -mmacosx-version-min=11.0 +endif + +.PHONY: build cli install test app icons release release-cli clean + +build: + go build -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" -o dial . + +cli: + CGO_ENABLED=0 go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dial . + +install: + go install -tags $(TAGS) -trimpath -ldflags="$(LDFLAGS)" . + +test: + go vet ./... && go test ./... + +# macOS bundle: menu bar app with no Dock icon (LSUIElement). +app: build + @rm -rf dial.app + @mkdir -p dial.app/Contents/MacOS dial.app/Contents/Resources + @cp dial dial.app/Contents/MacOS/dial + @cp build/darwin/dial.icns dial.app/Contents/Resources/dial.icns + @sed 's/@VERSION@/$(VERSION)/' build/darwin/Info.plist > dial.app/Contents/Info.plist + @echo " dial.app" + +icons: + @go run build/icon/gen.go tray internal/gui/tray.png + @go run build/icon/gen.go app 64 internal/gui/icon.png + @rm -rf build/darwin/dial.iconset && mkdir -p build/darwin/dial.iconset + @for s in 16 32 128 256 512; do \ + go run build/icon/gen.go app $$s build/darwin/dial.iconset/icon_$${s}x$${s}.png; \ + go run build/icon/gen.go app $$((s*2)) build/darwin/dial.iconset/icon_$${s}x$${s}@2x.png; \ + done + @iconutil -c icns build/darwin/dial.iconset -o build/darwin/dial.icns && rm -rf build/darwin/dial.iconset + +release: clean build + @mkdir -p dist + @cp dial dist/dial-$(shell go env GOOS)-$(shell go env GOARCH) + @$(MAKE) --no-print-directory release-cli + +release-cli: + @mkdir -p dist + @for t in $(TARGETS); do \ + os=$${t%/*}; arch=$${t#*/}; ext=""; [ $$os = windows ] && ext=.exe; \ + echo " $$os/$$arch (cli)"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -tags nogui -trimpath -ldflags="$(LDFLAGS)" -o dist/dial-cli-$$os-$$arch$$ext . ; \ + done + +clean: + rm -rf dial dial.exe dial.app dist diff --git a/README.md b/README.md new file mode 100644 index 00000000..9abd748f --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# dial + +One small dial for every coding agent's model. + +`dial` is a single screen that lists each AI coding agent on your machine and +the model it is set to. Click a value, pick a model. That is the whole app. + +It lives in the menu bar: click the icon and a panel drops down; the same +screen also opens as a normal window (`dial`, or *Open dial* in the tray menu), +and there is a terminal version (`dial tui`) and a plain CLI. + +``` + ◉ dial + + ▸ Claude Code claude-fable-5-1[1m] ~/.claude/settings.json + Codex gpt-6-astra effort medium + Gemini CLI gemini-3.1-pro + OpenCode anthropic/claude-sonnet-5 small anthropic/claude-haiku-4-5 + Pi openrouter/z-ai/glm-5.2:batch + Goose anthropic/claude-sonnet-5 + Cursor auto + Copilot CLI claude-fable-5 + + ↑↓ agent · ←→ field · ↵ change · s save profile · p profiles · q quit +``` + +- **One small binary.** About 11 MB with the desktop app (it uses the system + webview through [Wails](https://wails.io), nothing bundled), 7 MB for the + terminal-only build. macOS, Linux and Windows. +- **Edits config files surgically.** Only the one key you change is touched; + comments, ordering and indentation in your `settings.json`, `config.toml`, + `opencode.jsonc` or `config.yaml` survive intact. Writes are atomic. +- **Real model lists.** When you switch to a provider, dial asks that vendor + which models it serves (`GET /models` with your key) and offers exactly + those; the [models.dev](https://models.dev) catalog, Codex's own model cache + and a built-in list fill in when a vendor has no list. Anything can also be + typed in. +- **Providers.** DeepSeek, Kimi, GLM, MiniMax, Qwen, OpenRouter and any + Anthropic- or Responses-compatible gateway you add, each with its key, a + connection test and one-click switching of every agent that can use it. +- **Profiles.** Snapshot every agent's settings under a name and switch all of + them back in one move. +- **Real logos, no framework.** Plain HTML over the system webview; brand + icons from [lobehub/icons](https://github.com/lobehub/lobe-icons). + +## Agents + +| Agent | File | Fields | +| ------------ | --------------------------------- | --------------- | +| Claude Code | `~/.claude/settings.json` | provider, model | +| Codex | `~/.codex/config.toml` | provider, model, effort | +| Gemini CLI | `~/.gemini/settings.json`, `~/.gemini/.env` | auth, model | +| OpenCode | `~/.config/opencode/opencode.json(c)` | model, small | +| Pi | `~/.pi/agent/settings.json` | model | +| Goose | `~/.config/goose/config.yaml` | model | +| Cursor CLI | `~/.cursor/cli-config.json` | model | +| Copilot CLI | `~/.copilot/settings.json` | model | +| Crush | `~/.config/crush/crush.json` | large, small | + +Provider-scoped agents (OpenCode, Pi, Goose, Crush) take `provider/model`. +Only agents that are installed or configured are shown. + +## Providers + +The *Providers* page (or `dial providers`) lists every vendor dial knows: +its endpoints, whether a key is present, how many models it serves and +which agents use it. Built-ins: DeepSeek, Kimi, Zhipu/Z.ai GLM, MiniMax, +Qwen and OpenRouter, each with a global and a China endpoint where the vendor +has one. Any Anthropic-Messages or OpenAI-Responses compatible endpoint can +be added; built-ins can be edited, hidden or reset. + +```sh +dial providers # who is configured, who has a key, who uses what +dial provider deepseek # endpoints, key, the models it serves +dial provider add "My Gateway" anthropic=https://gw.example.com/anthropic env=GW_API_KEY catalog=anthropic +dial provider edit kimi models=kimi-k3-preview +dial provider test deepseek # one tiny request per endpoint, with latency +dial provider models deepseek # re-fetch the vendor's model list +dial provider rm kimi # hide a built-in (reset brings it back) +dial key DEEPSEEK_API_KEY sk-… # keys for apps that see no shell env +``` + +Keys come from your shell environment first, then from +`~/.config/dial/keys` (mode 0600). The desktop app asks for a key the first +time you pick a provider that has none. + +**Claude Code** — `dial claude provider deepseek` sets `ANTHROPIC_BASE_URL`, +`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL` and the small-model variable in the +`env` block of `settings.json`; `provider anthropic` removes them and +restores whatever was there. Claude Code re-reads its settings, so a running +session picks the change up. + +**Codex** — `dial codex provider deepseek` adds a `[model_providers.deepseek]` +table to `config.toml`, points `model_catalog_json` at +`~/.codex/dial-models.json` (a catalog dial writes from the vendor's live +model list) and keeps `model`/`effort` valid. `provider openai` removes all +of that; your ChatGPT sign-in is never touched. Any `[model_providers.*]` +table you wrote yourself shows up as a provider too. Codex builds its model +list once at start-up, so the Codex app (and open `codex` sessions) must be +restarted to see the new list; dial says so after the switch. + +**Gemini CLI** — `dial gemini auth api-key|google|vertex` sets +`security.auth.selectedType`; the API key goes to `~/.gemini/.env`. + +Only DeepSeek has been verified end to end against a real account so far; +the other built-ins follow the vendors' documented endpoints. + +## Install + +```sh +go install github.com/yetone/dial@latest +``` + +or build locally: + +```sh +make build # ./dial with the desktop app (needs cgo + the platform webview) +make app # macOS: dial.app, a menu bar app with no Dock icon +make cli # terminal-only build, no cgo, cross-compiles anywhere +make release # dist/: native app build + cli builds for every platform +``` + +Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` for the app build; +Windows uses the WebView2 runtime that ships with the OS. + +## Use + +```sh +dial # open the app: a window plus the menu bar icon +dial tray # menu bar icon only (use this in your login items) +dial tui # the same dial, in the terminal +dial ls # list every agent and its current settings +dial claude opus # set a model (agent names accept prefixes: cc, oc, gem …) +dial codex gpt-5.6-sol +dial codex effort high # other fields +dial codex xhigh # bare effort levels are recognised too +dial codex provider deepseek # run Codex against DeepSeek (needs DEEPSEEK_API_KEY) +dial claude provider kimi # Claude Code through Kimi's Anthropic-compatible endpoint +dial gemini auth api-key +dial opencode anthropic/claude-sonnet-5 +dial oc small anthropic/claude-haiku-4-5 + +dial save work # snapshot everything as a profile +dial use work # switch back +dial profiles +dial rm work + +dial sync # refresh the models.dev catalog and every live model list +``` + +In the app, click any value to open a filtered list; type to search or to +enter something that is not listed; `esc` closes the panel. Profiles are the +chips at the bottom: click to apply, `×` to delete, *+ save current* to add. +The *Providers* tab of the window opens each provider inline: endpoints, key, +catalog, extra models, *Test connection* and a button per agent that can use +it. + +Keys in the terminal dial: + +| Key | Action | +| ---------- | ------------------------------------- | +| `↑` `↓` | choose agent | +| `←` `→` | choose field (model, effort, small …) | +| `↵` | open the picker | +| type | filter; enter accepts custom values | +| `s` | save current setup as a profile | +| `p` | apply or delete (`ctrl+d`) a profile | +| `S` | sync the model catalog | +| `q` | quit | + +Agents read their config at startup, so a running session keeps its model +until you start a new one. + +## Files + +- `~/.config/dial/profiles.json` — saved profiles +- `~/.config/dial/providers.json` — your providers and edits to built-ins +- `~/.config/dial/keys` — stored API keys (0600) +- `~/.config/dial/stash.json` — values dial replaced, restored on switch-back +- `~/.cache/dial/models.json` — models.dev catalog (OpenCode's cache at + `~/.cache/opencode/models.json` is used when present) +- `~/.cache/dial/models/.json` — model lists fetched from vendors + +`XDG_CONFIG_HOME` and `XDG_CACHE_HOME` are respected. diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 00000000..8f650989 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleNamedial + CFBundleDisplayNamedial + CFBundleIdentifiercom.yetone.dial + CFBundleExecutabledial + CFBundleIconFiledial + CFBundlePackageTypeAPPL + CFBundleShortVersionString@VERSION@ + CFBundleVersion@VERSION@ + LSMinimumSystemVersion11.0 + LSUIElement + NSHighResolutionCapable + NSHumanReadableCopyrightyetone + + diff --git a/build/darwin/dial.icns b/build/darwin/dial.icns new file mode 100644 index 0000000000000000000000000000000000000000..3e5b276bce34a40b53df12f373ee2c18cf44467a GIT binary patch literal 102870 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 PZy|pKfByLW@67)I8!Qu% literal 0 HcmV?d00001 diff --git a/build/icon/gen.go b/build/icon/gen.go new file mode 100644 index 00000000..6952bfa7 --- /dev/null +++ b/build/icon/gen.go @@ -0,0 +1,151 @@ +//go:build ignore + +// Icon generator. Everything dial shows is drawn from one glyph: a ring with a +// gap at the bottom and a pointer, like a knob. +// +// go run build/icon/gen.go tray internal/gui/tray.png # 44px black template icon (macOS menu bar) +// go run build/icon/gen.go app 64 internal/gui/icon.png # coloured app icon at a given size +// make icons # regenerates all of them plus dial.icns +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "strconv" +) + +func main() { + var img image.Image + switch { + case len(os.Args) == 3 && os.Args[1] == "tray": + img = tray() + case len(os.Args) == 4 && os.Args[1] == "app": + n, _ := strconv.Atoi(os.Args[2]) + img = app(n) + default: + fmt.Fprintln(os.Stderr, "usage: gen.go tray | gen.go app ") + os.Exit(2) + } + f, err := os.Create(os.Args[len(os.Args)-1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// tray draws the glyph as a macOS template image (black + alpha). +func tray() image.Image { + const S = 44 + img := image.NewNRGBA(image.Rect(0, 0, S, S)) + cx, cy := float64(S)/2, float64(S)/2 + rOuter, rInner := 17.5, 14.0 + px, py := cx+8.0*math.Cos(-math.Pi/4), cy+8.0*math.Sin(-math.Pi/4) // pointer tip, upper right + for y := 0; y < S; y++ { + for x := 0; x < S; x++ { + a := 0.0 + // supersample 4x4 + for sy := 0; sy < 4; sy++ { + for sx := 0; sx < 4; sx++ { + fx := float64(x) + (float64(sx)+0.5)/4 + fy := float64(y) + (float64(sy)+0.5)/4 + dx, dy := fx-cx, fy-cy + d := math.Hypot(dx, dy) + ang := math.Atan2(dy, dx) // -pi..pi, y down + // ring with a gap at the bottom (like a knob's travel) + inRing := d <= rOuter && d >= rInner && !(ang > math.Pi/2-0.55 && ang < math.Pi/2+0.55) + // pointer: thick segment from centre to tip + inPtr := segDist(fx, fy, cx, cy, px, py) <= 2.2 + // hub + inHub := d <= 3.4 + if inRing || inPtr || inHub { + a++ + } + } + } + img.SetNRGBA(x, y, color.NRGBA{0, 0, 0, uint8(a / 16 * 255)}) + } + } + return img +} + +// app draws a rounded indigo square with the glyph in white. +func app(n int) image.Image { + S := float64(n) + img := image.NewNRGBA(image.Rect(0, 0, n, n)) + // macOS icon grid: the squircle occupies ~80% of the canvas + inset := S * 0.1 + side := S - 2*inset + radius := side * 0.225 + cx, cy := S/2, S/2 + rOuter, rInner := side*0.30, side*0.235 + ptrLen, ptrW := side*0.135, side*0.04 + px, py := cx+ptrLen*math.Cos(-math.Pi/4), cy+ptrLen*math.Sin(-math.Pi/4) + hub := side * 0.062 + bg1 := [3]float64{0x5b, 0x55, 0xf0} // top: lighter indigo + bg2 := [3]float64{0x3f, 0x37, 0xc9} // bottom: deeper + ss := 4 + if n <= 64 { + ss = 8 + } + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + var aBg, aFg float64 + for sy := 0; sy < ss; sy++ { + for sx := 0; sx < ss; sx++ { + fx := float64(x) + (float64(sx)+0.5)/float64(ss) + fy := float64(y) + (float64(sy)+0.5)/float64(ss) + if roundRect(fx, fy, inset, inset, side, side, radius) { + aBg++ + dx, dy := fx-cx, fy-cy + d := math.Hypot(dx, dy) + ang := math.Atan2(dy, dx) + inRing := d <= rOuter && d >= rInner && !(ang > math.Pi/2-0.55 && ang < math.Pi/2+0.55) + inPtr := segDist(fx, fy, cx, cy, px, py) <= ptrW + if inRing || inPtr || d <= hub { + aFg++ + } + } + } + } + tot := float64(ss * ss) + t := float64(y) / S + r := bg1[0]*(1-t) + bg2[0]*t + g := bg1[1]*(1-t) + bg2[1]*t + b := bg1[2]*(1-t) + bg2[2]*t + fg := aFg / tot + if aBg > 0 { + fg /= aBg / tot + } + img.SetNRGBA(x, y, color.NRGBA{ + uint8(r*(1-fg) + 255*fg), uint8(g*(1-fg) + 255*fg), uint8(b*(1-fg) + 255*fg), + uint8(aBg / tot * 255), + }) + } + } + return img +} + +func roundRect(px, py, x, y, w, h, r float64) bool { + if px < x || py < y || px > x+w || py > y+h { + return false + } + qx := math.Max(math.Abs(px-(x+w/2))-(w/2-r), 0) + qy := math.Max(math.Abs(py-(y+h/2))-(h/2-r), 0) + return math.Hypot(qx, qy) <= r +} + +func segDist(px, py, ax, ay, bx, by float64) float64 { + vx, vy := bx-ax, by-ay + wx, wy := px-ax, py-ay + t := math.Max(0, math.Min(1, (wx*vx+wy*vy)/(vx*vx+vy*vy))) + return math.Hypot(px-(ax+t*vx), py-(ay+t*vy)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..c624719c --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/yetone/dial + +go 1.26.3 + +require ( + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/tidwall/gjson v1.19.0 + github.com/tidwall/jsonc v0.3.3 + github.com/wailsapp/wails/v3 v3.0.0-beta.24 +) + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..c430d7e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,87 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w= +github.com/tidwall/jsonc v0.3.3/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/wailsapp/wails/v3 v3.0.0-beta.24 h1:YmD11T9FYtuKIBgyC7JbkwPzLqNZ2IysQ7rZ3pDG1tI= +github.com/wailsapp/wails/v3 v3.0.0-beta.24/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gui_off.go b/gui_off.go new file mode 100644 index 00000000..30fcc573 --- /dev/null +++ b/gui_off.go @@ -0,0 +1,9 @@ +//go:build nogui + +package main + +import "errors" + +const hasGUI = false + +func runGUI(bool) error { return errors.New("this build has no GUI; run `dial tui`") } diff --git a/gui_on.go b/gui_on.go new file mode 100644 index 00000000..9ad84a21 --- /dev/null +++ b/gui_on.go @@ -0,0 +1,9 @@ +//go:build !nogui + +package main + +import "github.com/yetone/dial/internal/gui" + +const hasGUI = true + +func runGUI(showMain bool) error { return gui.Run(version, showMain) } diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 00000000..cfcbfc85 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,144 @@ +// Package agent describes every coding agent dial can drive: where its config +// lives, which fields matter (model, effort, …) and which values to offer. +package agent + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "sort" + "strings" +) + +// Option is one value the picker offers for a field. +type Option struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` // display name, when the value is an id + Note string `json:"note"` + Key string `json:"key,omitempty"` // env var the option needs, if any + NeedKey bool `json:"needKey,omitempty"` // that key is not set anywhere yet + Icon string `json:"icon,omitempty"` // bundled icon name +} + +// Field is one tunable setting of an agent. +type Field struct { + Key string + Label string + Get func() string + Set func(string) error + Options func(cur map[string]string) []Option +} + +// Agent is one supported coding agent. +type Agent struct { + ID string + Name string + Icon string // bundled icon name (internal/gui/assets/icons) + Aliases []string + Bin string // executable name, used for detection + Dir string // config directory, used for detection + Path string // config file dial edits + Fields []Field + // Notice, if set, is advice worth showing after a change: agents that + // read their config once at start-up need a restart to see it. + Notice func() string +} + +// Running reports whether a process whose command line matches any pattern +// (an extended regexp, as for pgrep -f) is alive. Unknown on Windows. +func Running(patterns ...string) bool { + if runtime.GOOS == "windows" { + return false + } + for _, pat := range patterns { + if err := exec.Command("pgrep", "-f", pat).Run(); err == nil { + return true + } + } + return false +} + +// Detected reports whether the agent seems to be installed or configured. +func (a *Agent) Detected() bool { + if _, err := os.Stat(a.Path); err == nil { + return true + } + if a.Dir != "" { + if _, err := os.Stat(a.Dir); err == nil { + return true + } + } + if a.Bin != "" { + if _, err := exec.LookPath(a.Bin); err == nil { + return true + } + } + return false +} + +// Field looks a field up by key. +func (a *Agent) Field(key string) *Field { + for i := range a.Fields { + if a.Fields[i].Key == key { + return &a.Fields[i] + } + } + return nil +} + +// Values reads every field. +func (a *Agent) Values() map[string]string { + m := make(map[string]string, len(a.Fields)) + for _, f := range a.Fields { + m[f.Key] = f.Get() + } + return m +} + +// Detected returns the agents present on this machine, in display order. +func Detected() []*Agent { + var out []*Agent + for _, a := range All() { + if a.Detected() { + out = append(out, a) + } + } + return out +} + +// Find resolves a user-typed name (id, alias, or unique prefix). +func Find(q string) (*Agent, error) { + q = strings.ToLower(strings.TrimSpace(q)) + all := All() + var prefix []*Agent + for _, a := range all { + if a.ID == q { + return a, nil + } + for _, al := range a.Aliases { + if al == q { + return a, nil + } + } + if strings.HasPrefix(a.ID, q) || strings.HasPrefix(strings.ToLower(a.Name), q) { + prefix = append(prefix, a) + } + } + switch len(prefix) { + case 1: + return prefix[0], nil + case 0: + return nil, fmt.Errorf("unknown agent %q (try: %s)", q, strings.Join(ids(all), ", ")) + } + return nil, fmt.Errorf("%q is ambiguous: %s", q, strings.Join(ids(prefix), ", ")) +} + +func ids(as []*Agent) []string { + s := make([]string, len(as)) + for i, a := range as { + s[i] = a.ID + } + sort.Strings(s) + return s +} diff --git a/internal/agent/agents.go b/internal/agent/agents.go new file mode 100644 index 00000000..a2ebbfa2 --- /dev/null +++ b/internal/agent/agents.go @@ -0,0 +1,255 @@ +package agent + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/yetone/dial/internal/catalog" + "github.com/yetone/dial/internal/edit" +) + +// All returns every agent dial knows about, detected or not. +func All() []*Agent { + home, _ := os.UserHomeDir() + cfg := os.Getenv("XDG_CONFIG_HOME") + if cfg == "" { + cfg = filepath.Join(home, ".config") + } + return []*Agent{ + claude(home), + codex(home), + gemini(home), + opencode(home, cfg), + pi(home), + goose(home, cfg), + cursor(home), + copilot(home), + crush(home, cfg), + } +} + +// ---- accessors ------------------------------------------------------------- + +func jsonGet(path, key string) func() string { + return func() string { v, _ := edit.GetJSON(path, key); return v } +} + +func jsonSet(path, key string) func(string) error { + return func(v string) error { return edit.SetJSON(path, edit.KV{Path: key, Value: v}) } +} + +// pair joins a provider field and a model field into one "provider/model" +// value, which is how OpenCode already spells it and how people think of it. +func pairGet(get func(string) (string, bool), pKey, mKey string) func() string { + return func() string { + p, _ := get(pKey) + m, _ := get(mKey) + switch { + case m == "": + return "" + case p == "": + return m + } + return p + "/" + m + } +} + +func pairSet(set func(...edit.KV) error, pKey, mKey string) func(string) error { + return func(v string) error { + p, m, ok := strings.Cut(v, "/") + if !ok || p == "" || m == "" { + return fmt.Errorf("expected provider/model, got %q", v) + } + return set(edit.KV{Path: pKey, Value: p}, edit.KV{Path: mKey, Value: m}) + } +} + +// ---- option builders ------------------------------------------------------- + +func options(models []catalog.Model, prefix string) []Option { + out := make([]Option, 0, len(models)) + for _, m := range models { + out = append(out, Option{Value: prefix + m.ID, Note: m.Name}) + } + return out +} + +func static(vals ...string) []Option { + out := make([]Option, len(vals)) + for i, v := range vals { + out[i] = Option{Value: v} + } + return out +} + +// providerOptions lists provider/model pairs for every provider the user has +// credentials for: keys of an auth file, API-key env vars, and whatever the +// current value already uses. +func providerOptions(authFile string, cur string, extra ...string) []Option { + set := map[string]bool{} + for _, p := range extra { + set[p] = true + } + if p, _, ok := strings.Cut(cur, "/"); ok { + set[p] = true + } + if b, err := os.ReadFile(authFile); err == nil { + var m map[string]json.RawMessage + if json.Unmarshal(b, &m) == nil { + for k := range m { + set[k] = true + } + } + } + for _, p := range catalog.Providers() { + if catalog.ProviderAvailable(p) { + set[p] = true + } + } + providers := make([]string, 0, len(set)) + for p := range set { + providers = append(providers, p) + } + sort.Strings(providers) + var out []Option + for _, p := range providers { + out = append(out, options(catalog.Provider(p), p+"/")...) + } + return out +} + +// ---- agents ---------------------------------------------------------------- + +func opencode(home, cfg string) *Agent { + dir := filepath.Join(cfg, "opencode") + path := filepath.Join(dir, "opencode.json") + if _, err := os.Stat(filepath.Join(dir, "opencode.jsonc")); err == nil { + path = filepath.Join(dir, "opencode.jsonc") + } + auth := filepath.Join(home, ".local", "share", "opencode", "auth.json") + opts := func(key string) func(map[string]string) []Option { + return func(cur map[string]string) []Option { return providerOptions(auth, cur[key]) } + } + return &Agent{ + ID: "opencode", Name: "OpenCode", Icon: "opencode", Aliases: []string{"oc"}, + Bin: "opencode", Dir: dir, Path: path, + Fields: []Field{ + {Key: "model", Label: "model", Get: jsonGet(path, "model"), Set: jsonSet(path, "model"), Options: opts("model")}, + {Key: "small", Label: "small", Get: jsonGet(path, "small_model"), Set: jsonSet(path, "small_model"), Options: opts("small")}, + }, + } +} + +func pi(home string) *Agent { + dir := filepath.Join(home, ".pi", "agent") + path := filepath.Join(dir, "settings.json") + auth := filepath.Join(dir, "auth.json") + get := func(k string) (string, bool) { return edit.GetJSON(path, k) } + set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) } + return &Agent{ + ID: "pi", Name: "Pi", Icon: "pi", Bin: "pi", Dir: dir, Path: path, + Fields: []Field{{ + Key: "model", Label: "model", + Get: pairGet(get, "defaultProvider", "defaultModel"), + Set: pairSet(set, "defaultProvider", "defaultModel"), + Options: func(cur map[string]string) []Option { return providerOptions(auth, cur["model"]) }, + }}, + } +} + +func goose(home, cfg string) *Agent { + path := filepath.Join(cfg, "goose", "config.yaml") + if runtime.GOOS == "windows" { + if app := os.Getenv("APPDATA"); app != "" { + path = filepath.Join(app, "Block", "goose", "config", "config.yaml") + } + } + get := func(k string) (string, bool) { return edit.GetYAMLTop(path, k) } + set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) } + return &Agent{ + ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, + Fields: []Field{{ + Key: "model", Label: "model", + Get: pairGet(get, "GOOSE_PROVIDER", "GOOSE_MODEL"), + Set: pairSet(set, "GOOSE_PROVIDER", "GOOSE_MODEL"), + Options: func(cur map[string]string) []Option { return providerOptions("", cur["model"]) }, + }}, + } +} + +func cursor(home string) *Agent { + path := filepath.Join(home, ".cursor", "cli-config.json") + return &Agent{ + ID: "cursor", Name: "Cursor", Icon: "cursor", Aliases: []string{"cursor-agent"}, + Bin: "cursor-agent", Dir: filepath.Dir(path), Path: path, + Fields: []Field{{ + Key: "model", Label: "model", + Get: jsonGet(path, "model.modelId"), + Set: func(v string) error { + return edit.SetJSON(path, + edit.KV{Path: "model.modelId", Value: v}, + edit.KV{Path: "model.displayModelId", Value: v}, + edit.KV{Path: "model.displayName", Value: v}, + edit.KV{Path: "hasChangedDefaultModel", Value: true}, + ) + }, + Options: func(map[string]string) []Option { + return []Option{{Value: "auto", Note: "let Cursor pick"}} + }, + }}, + } +} + +func copilot(home string) *Agent { + dir := filepath.Join(home, ".copilot") + path := filepath.Join(dir, "settings.json") + return &Agent{ + ID: "copilot", Name: "Copilot CLI", Icon: "githubcopilot", Aliases: []string{"gh-copilot"}, + Bin: "copilot", Dir: dir, Path: path, + Fields: []Field{{ + Key: "model", Label: "model", + Get: jsonGet(path, "model"), + Set: jsonSet(path, "model"), + Options: func(map[string]string) []Option { return options(catalog.Builtin("copilot"), "") }, + }}, + } +} + +func crush(home, cfg string) *Agent { + path := filepath.Join(cfg, "crush", "crush.json") + if runtime.GOOS == "windows" { + if app := os.Getenv("LOCALAPPDATA"); app != "" { + path = filepath.Join(app, "crush", "crush.json") + } + } + get := func(k string) (string, bool) { return edit.GetJSON(path, k) } + set := func(kvs ...edit.KV) error { return edit.SetJSON(path, kvs...) } + opts := func(key string) func(map[string]string) []Option { + return func(cur map[string]string) []Option { + var extra []string + if b, err := os.ReadFile(path); err == nil { + var c struct { + Providers map[string]json.RawMessage `json:"providers"` + } + if json.Unmarshal(b, &c) == nil { + for p := range c.Providers { + extra = append(extra, p) + } + } + } + return providerOptions("", cur[key], extra...) + } + } + return &Agent{ + ID: "crush", Name: "Crush", Icon: "crush", Bin: "crush", Dir: filepath.Dir(path), Path: path, + Fields: []Field{ + {Key: "model", Label: "large", Get: pairGet(get, "models.large.provider", "models.large.model"), Set: pairSet(set, "models.large.provider", "models.large.model"), Options: opts("model")}, + {Key: "small", Label: "small", Get: pairGet(get, "models.small.provider", "models.small.model"), Set: pairSet(set, "models.small.provider", "models.small.model"), Options: opts("small")}, + }, + } +} diff --git a/internal/agent/claude.go b/internal/agent/claude.go new file mode 100644 index 00000000..7fef3c05 --- /dev/null +++ b/internal/agent/claude.go @@ -0,0 +1,172 @@ +package agent + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/yetone/dial/internal/catalog" + "github.com/yetone/dial/internal/edit" +) + +// Claude Code reads its endpoint from the `env` block of settings.json: +// ANTHROPIC_BASE_URL plus ANTHROPIC_AUTH_TOKEN point it at any vendor that +// speaks the Anthropic Messages API, and the ANTHROPIC_DEFAULT_*_MODEL +// variables tell it what "opus", "sonnet" and "haiku" mean over there. + +var claudeAliases = []Option{ + {Value: "opus", Note: "alias · latest Opus"}, + {Value: "sonnet", Note: "alias · latest Sonnet"}, + {Value: "haiku", Note: "alias · latest Haiku"}, + {Value: "opusplan", Note: "alias · Opus for planning, Sonnet for work"}, + {Value: "sonnet[1m]", Note: "alias · Sonnet with 1M context"}, +} + +// env vars dial owns on a third-party provider; all go away on the way back. +var claudeEnv = []string{ + "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", +} + +func claude(home string) *Agent { + path := filepath.Join(home, ".claude", "settings.json") + env := func(k string) string { v, _ := edit.GetJSON(path, "env."+k); return v } + model := jsonGet(path, "model") + + // current provider: "anthropic" (no base URL), a preset id, or "custom". + current := func() (string, *Provider) { + base := env("ANTHROPIC_BASE_URL") + if base == "" { + return "anthropic", nil + } + if p := providerByURL(base, func(p Provider) string { return p.Anthropic }); p != nil { + return p.ID, p + } + return "custom", nil + } + // write the model everywhere Claude Code looks for it on a third party. + setModel := func(p *Provider, m string) error { + kvs := []edit.KV{{Path: "model", Value: m}} + if p != nil { + small := p.Small + if small == "" { + small = m + } + kvs = append(kvs, + edit.KV{Path: "env.ANTHROPIC_MODEL", Value: m}, + edit.KV{Path: "env.ANTHROPIC_DEFAULT_OPUS_MODEL", Value: m}, + edit.KV{Path: "env.ANTHROPIC_DEFAULT_SONNET_MODEL", Value: m}, + edit.KV{Path: "env.ANTHROPIC_DEFAULT_HAIKU_MODEL", Value: small}, + edit.KV{Path: "env.ANTHROPIC_SMALL_FAST_MODEL", Value: small}, + ) + } + return edit.SetJSON(path, kvs...) + } + use := func(id string) error { + cur, _ := current() + switch id { + case "custom": + if cur != "custom" { + return fmt.Errorf("custom means whatever ANTHROPIC_BASE_URL is already in %s; set it there", path) + } + return nil + case "anthropic": + keys := make([]string, len(claudeEnv)) + for i, k := range claudeEnv { + keys[i] = "env." + k + } + if err := edit.DelJSON(path, keys...); err != nil { + return err + } + // a vendor's model id means nothing to Anthropic: restore what was + // set before leaving, else Claude Code's own default. + if m := model(); m != "" && !strings.HasPrefix(m, "claude") && !isAlias(m) { + if prev := unstash("claude.model"); prev != "" { + return edit.SetJSON(path, edit.KV{Path: "model", Value: prev}) + } + return edit.DelJSON(path, "model") + } + return nil + } + p := provider(id) + if p == nil || p.Anthropic == "" { + return fmt.Errorf("unknown provider %q", id) + } + key := Key(p.EnvKey) + if key == "" { + return needKey(*p) + } + if cur == "anthropic" { + stash(map[string]string{"claude.model": model()}) + } + p.refreshQuietly() + if err := edit.SetJSON(path, + edit.KV{Path: "env.ANTHROPIC_BASE_URL", Value: p.Anthropic}, + edit.KV{Path: "env.ANTHROPIC_AUTH_TOKEN", Value: key}, + ); err != nil { + return err + } + ms := p.Models() + m := model() + if !hasModel(ms, m) && len(ms) > 0 { + m = ms[0].ID + } + return setModel(p, m) + } + + return &Agent{ + ID: "claude", Name: "Claude Code", Icon: "claudecode-color", Aliases: []string{"cc", "claude-code"}, + Bin: "claude", Dir: filepath.Dir(path), Path: path, + Fields: []Field{ + { + Key: "provider", Label: "provider", + Get: func() string { id, _ := current(); return id }, + Set: use, + Options: func(map[string]string) []Option { + out := []Option{{Value: "anthropic", Label: "Anthropic", Icon: "claude-color", Note: "Claude sign-in / $ANTHROPIC_API_KEY"}} + var ps []Option + for _, p := range Providers() { + if p.Anthropic != "" { + ps = append(ps, p.option(p.Anthropic)) + } + } + out = append(out, sortReady(ps)...) + if id, _ := current(); id == "custom" { + out = append(out, Option{Value: "custom", Note: hostOf(env("ANTHROPIC_BASE_URL")) + " (from settings.json)"}) + } + return out + }, + }, + { + Key: "model", Label: "model", + Get: model, + Set: func(v string) error { _, p := current(); return setModel(p, v) }, + Options: func(map[string]string) []Option { + if _, p := current(); p != nil { + return options(p.Models(), "") + } + return append(append([]Option{}, claudeAliases...), options(catalog.Provider("anthropic"), "")...) + }, + }, + }, + } +} + +func isAlias(m string) bool { + for _, a := range claudeAliases { + if a.Value == m { + return true + } + } + return false +} + +func hasModel(ms []catalog.Model, id string) bool { + for _, m := range ms { + if m.ID == id { + return true + } + } + return false +} diff --git a/internal/agent/codex.go b/internal/agent/codex.go new file mode 100644 index 00000000..520c3b37 --- /dev/null +++ b/internal/agent/codex.go @@ -0,0 +1,328 @@ +package agent + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/yetone/dial/internal/catalog" + "github.com/yetone/dial/internal/edit" +) + +// Codex talks the OpenAI Responses API to whichever provider config.toml +// names. Switching provider means three things: a [model_providers.] +// table, `model_provider = ""`, and a model catalog file so the models +// show up inside Codex's own /model picker. + +// codexPrompt is Codex's generic system prompt (Apache-2.0, openai/codex, +// core/gpt-5.2-codex_prompt.md). Third-party models need one because the +// bundled catalog only carries prompts for OpenAI models. +// +//go:embed codex_prompt.md +var codexPrompt string + +// CodexProvider is an endpoint Codex can be pointed at. +type CodexProvider struct { + ID string + Name string + BaseURL string // must speak the Responses API at /responses + EnvKey string // environment variable holding the API key + Catalog string // models.dev provider id for the model list + Icon string + dial *Provider // the dial provider behind a preset + custom bool // found in config.toml, not a dial preset +} + +// codexPresets are "openai" (Codex's own default, expressed by the absence +// of model_provider) plus every provider with a Responses endpoint. +func codexPresets() []CodexProvider { + out := []CodexProvider{{ID: "openai", Name: "OpenAI", Icon: "openai"}} + for _, p := range Providers() { + if p.Responses != "" { + out = append(out, CodexProvider{ID: p.ID, Name: p.Name, BaseURL: p.Responses, EnvKey: p.EnvKey, Catalog: p.Catalog, Icon: p.Icon, dial: &p}) + } + } + return out +} + +func codex(home string) *Agent { + dir := filepath.Join(home, ".codex") + path := filepath.Join(dir, "config.toml") + catalogPath := filepath.Join(dir, "dial-models.json") + get := func(k string) string { v, _ := edit.GetTOMLTop(path, k); return v } + + providers := func() []CodexProvider { + out := codexPresets() + for _, t := range edit.TOMLTables(path) { + id, ok := strings.CutPrefix(t, "model_providers.") + if !ok || strings.Contains(id, ".") { + continue + } + if p := findProvider(out, id); p != nil { + continue + } + kv := edit.GetTOMLTable(path, t) + out = append(out, CodexProvider{ID: id, Name: kv["name"], BaseURL: kv["base_url"], EnvKey: kv["env_key"], custom: true}) + } + return out + } + current := func() CodexProvider { + id := get("model_provider") + if id == "" { + id = "openai" + } + if p := findProvider(providers(), id); p != nil { + return *p + } + return CodexProvider{ID: id, Name: id, custom: true} + } + models := func(p CodexProvider) []catalog.Model { + switch { + case p.ID == "openai": + return catalog.Codex() + case p.dial != nil: + return p.dial.Models() + } + return nil + } + // keep model and effort valid for the provider; a fresh provider gets + // its first model and that model's default effort. + settle := func(p CodexProvider) error { + ms := models(p) + if len(ms) == 0 { + return nil + } + model, effort := get("model"), get("model_reasoning_effort") + found := false + for _, m := range ms { + if m.ID == model { + found = true + } + } + if !found { + model = ms[0].ID + } + var kvs []edit.KV + if model != get("model") { + kvs = append(kvs, edit.KV{Path: "model", Value: model}) + } + if e := catalog.Efforts(ms, model); len(e) > 0 && !contains(e, effort) { + kvs = append(kvs, edit.KV{Path: "model_reasoning_effort", Value: defaultEffort(e)}) + } + if len(kvs) == 0 { + return nil + } + return edit.SetTOMLTop(path, kvs...) + } + use := func(id string) error { + p := findProvider(providers(), id) + if p == nil { + return fmt.Errorf("unknown provider %q", id) + } + if p.ID == "openai" { + if err := edit.DelTOMLTop(path, "model_provider", "model_catalog_json"); err != nil { + return err + } + os.Remove(catalogPath) + if m := unstash("codex.model"); m != "" && !hasModel(models(*p), get("model")) { + var kvs []edit.KV + kvs = append(kvs, edit.KV{Path: "model", Value: m}) + if e := unstash("codex.effort"); e != "" { + kvs = append(kvs, edit.KV{Path: "model_reasoning_effort", Value: e}) + } + if err := edit.SetTOMLTop(path, kvs...); err != nil { + return err + } + } + return settle(*p) + } + if current().ID == "openai" { + stash(map[string]string{"codex.model": get("model"), "codex.effort": get("model_reasoning_effort")}) + } + if !p.custom { + key := Key(p.EnvKey) + if key == "" { + return needKey(Provider{Name: p.Name, EnvKey: p.EnvKey}) + } + p.dial.refreshQuietly() + kvs := []edit.KV{ + {Path: "name", Value: p.Name}, + {Path: "base_url", Value: p.BaseURL}, + {Path: "env_key", Value: p.EnvKey}, + {Path: "wire_api", Value: "responses"}, + } + // Codex reads env_key from its own environment; a key that only + // dial holds (desktop app, no shell) rides along as a bearer token. + if os.Getenv(p.EnvKey) == "" { + kvs = append(kvs, edit.KV{Path: "experimental_bearer_token", Value: key}) + } + if err := edit.SetTOMLTable(path, "model_providers."+p.ID, kvs...); err != nil { + return err + } + } + kvs := []edit.KV{{Path: "model_provider", Value: p.ID}} + if ms := models(*p); len(ms) > 0 { + if err := edit.WriteAtomic(catalogPath, codexCatalog(ms, *p)); err != nil { + return err + } + kvs = append(kvs, edit.KV{Path: "model_catalog_json", Value: catalogPath}) + } else if err := edit.DelTOMLTop(path, "model_catalog_json"); err != nil { + return err + } + if err := edit.SetTOMLTop(path, kvs...); err != nil { + return err + } + return settle(*p) + } + + return &Agent{ + ID: "codex", Name: "Codex", Icon: "codex-color", Bin: "codex", Dir: dir, Path: path, + // the app-server behind the Codex app (and every codex TUI) builds + // its model list once, at start-up. + Notice: func() string { + if Running(`(^|/)codex( |$)`) { + return "Codex builds its model list at start-up — restart the Codex app (and open codex sessions) to see this." + } + return "" + }, + Fields: []Field{ + { + Key: "provider", Label: "provider", + Get: func() string { return current().ID }, + Set: use, + Options: func(map[string]string) []Option { + var out []Option + for _, p := range providers() { + o := Option{Value: p.ID, Label: p.Name, Note: p.note(), Key: p.EnvKey, Icon: p.Icon} + o.NeedKey = p.EnvKey != "" && Key(p.EnvKey) == "" + out = append(out, o) + } + return append(out[:1], sortReady(out[1:])...) + }, + }, + { + Key: "model", Label: "model", + Get: func() string { return get("model") }, + Set: func(v string) error { + if err := edit.SetTOMLTop(path, edit.KV{Path: "model", Value: v}); err != nil { + return err + } + return settle(current()) + }, + Options: func(map[string]string) []Option { return options(models(current()), "") }, + }, + { + Key: "effort", Label: "effort", + Get: func() string { return get("model_reasoning_effort") }, + Set: func(v string) error { + return edit.SetTOMLTop(path, edit.KV{Path: "model_reasoning_effort", Value: v}) + }, + Options: func(cur map[string]string) []Option { + if e := catalog.Efforts(models(current()), cur["model"]); len(e) > 0 { + return static(e...) + } + return static("low", "medium", "high", "xhigh") + }, + }, + }, + } +} + +func (p CodexProvider) note() string { + switch { + case p.ID == "openai": + return "ChatGPT sign-in or OPENAI_API_KEY" + case p.EnvKey != "" && Key(p.EnvKey) == "": + return p.host() + " · $" + p.EnvKey + " not set" + case p.EnvKey != "": + return p.host() + " · $" + p.EnvKey + } + return p.host() +} + +func (p CodexProvider) host() string { return hostOf(p.BaseURL) } + +func findProvider(ps []CodexProvider, id string) *CodexProvider { + for i := range ps { + if ps[i].ID == id { + return &ps[i] + } + } + return nil +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} + +// defaultEffort picks the middle of the road: "medium" or "high" when +// offered, else whatever the list starts with. +func defaultEffort(e []string) string { + for _, want := range []string{"medium", "high"} { + if contains(e, want) { + return want + } + } + return e[0] +} + +// codexCatalog renders models in the shape of Codex's models.json so +// `model_catalog_json` can point at it. Only fields Codex requires or that +// change behaviour are set; the rest take Codex's defaults. +func codexCatalog(ms []catalog.Model, p CodexProvider) []byte { + type level struct { + Effort string `json:"effort"` + Description string `json:"description"` + } + type model struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Instructions string `json:"base_instructions"` + DefaultEffort *string `json:"default_reasoning_level"` + Efforts []level `json:"supported_reasoning_levels"` + Shell string `json:"shell_type"` + Visibility string `json:"visibility"` + InAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + Verbosity bool `json:"support_verbosity"` + DefVerbosity *string `json:"default_verbosity"` + ApplyPatch string `json:"apply_patch_tool_type"` + Truncation struct { + Mode string `json:"mode"` + Limit int `json:"limit"` + } `json:"truncation_policy"` + Tools []string `json:"experimental_supported_tools"` + Modalities []string `json:"input_modalities"` + } + var out struct { + Models []model `json:"models"` + } + for i, m := range ms { + e := model{ + Slug: m.ID, DisplayName: m.Name, Description: m.Name + " via " + p.host(), + Instructions: codexPrompt, Efforts: []level{}, + Shell: "unified_exec", Visibility: "list", InAPI: true, Priority: i + 1, + ApplyPatch: "freeform", Tools: []string{}, Modalities: []string{"text"}, + } + e.Truncation.Mode, e.Truncation.Limit = "tokens", 10000 + for _, ef := range m.Efforts { + e.Efforts = append(e.Efforts, level{Effort: ef}) + } + if len(m.Efforts) > 0 { + d := defaultEffort(m.Efforts) + e.DefaultEffort = &d + } + out.Models = append(out.Models, e) + } + b, _ := json.MarshalIndent(out, "", " ") + return b +} diff --git a/internal/agent/codex_prompt.md b/internal/agent/codex_prompt.md new file mode 100644 index 00000000..8e3f08fb --- /dev/null +++ b/internal/agent/codex_prompt.md @@ -0,0 +1,80 @@ +You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +## General + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) + +## Editing constraints + +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. + +## Plan tool + +When using the planning tool: +- Skip using the planning tool for straightforward tasks (roughly the easiest 25%). +- Do not make single-step plans. +- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. + +## Special user requests + +- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. +- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps. + +## Frontend tasks +When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts. +Aim for interfaces that feel intentional, bold, and a bit surprising. +- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system). +- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias. +- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions. +- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere. +- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs. +- Ensure the page loads properly on both desktop and mobile + +Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language. + +## Presenting your work and final message + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +- Default: be very concise; friendly coding teammate tone. +- Ask only when needed; suggest ideas; mirror the user's style. +- For substantial work, summarize clearly; follow final‑answer formatting. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only. +- No "save/copy this file" - User is on the same machine. +- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. +- For code changes: + * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. + * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. +- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. + +### Final answer structure and style guidelines + +- Plain text; CLI handles styling. Use structure only when it helps scanability. +- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help. +- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent. +- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. +- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. +- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording. +- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers. +- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets. +- File References: When referencing files in your response follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 diff --git a/internal/agent/gemini.go b/internal/agent/gemini.go new file mode 100644 index 00000000..0b5471ef --- /dev/null +++ b/internal/agent/gemini.go @@ -0,0 +1,119 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/yetone/dial/internal/catalog" + "github.com/yetone/dial/internal/edit" +) + +// Gemini CLI only speaks Google's own API, so "provider" here is how it +// authenticates: Google sign-in, a Gemini API key, or Vertex AI. The choice +// lives in settings.json (security.auth.selectedType); a key dial holds is +// handed over through ~/.gemini/.env, which the CLI loads at start-up. + +func gemini(home string) *Agent { + dir := filepath.Join(home, ".gemini") + path := filepath.Join(dir, "settings.json") + envPath := filepath.Join(dir, ".env") + auth := jsonGet(path, "security.auth.selectedType") + base := func() string { v, _ := edit.GetEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL"); return v } + + current := func() string { + if base() != "" { + return "custom" + } + switch auth() { + case "gemini-api-key": + return "api-key" + case "vertex-ai": + return "vertex" + } + return "google" + } + use := func(id string) error { + switch id { + case "custom": + if current() != "custom" { + return fmt.Errorf("custom means whatever GOOGLE_GEMINI_BASE_URL is already in %s; set it there", envPath) + } + return nil + case "google": + if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "oauth-personal"}); err != nil { + return err + } + case "vertex": + if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "vertex-ai"}); err != nil { + return err + } + case "api-key": + key := Key("GEMINI_API_KEY") + if key == "" { + return needKey(Provider{Name: "Gemini API", EnvKey: "GEMINI_API_KEY"}) + } + if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "gemini-api-key"}); err != nil { + return err + } + if os.Getenv("GEMINI_API_KEY") == "" { + if err := edit.SetEnvFile(envPath, edit.KV{Path: "GEMINI_API_KEY", Value: key}); err != nil { + return err + } + os.Chmod(envPath, 0o600) + } + default: + return fmt.Errorf("unknown provider %q", id) + } + return edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL") + } + + return &Agent{ + ID: "gemini", Name: "Gemini CLI", Icon: "geminicli-color", Aliases: []string{"gemini-cli"}, + Bin: "gemini", Dir: dir, Path: path, + Notice: func() string { + if Running(`(^|/)gemini( |$)`) { + return "Gemini CLI reads its settings at start-up — restart open gemini sessions to see this." + } + return "" + }, + Fields: []Field{ + { + Key: "provider", Label: "auth", + Get: current, + Set: use, + Options: func(map[string]string) []Option { + key := Option{Value: "api-key", Label: "API key", Key: "GEMINI_API_KEY", Icon: "gemini-color", Note: "Gemini API · $GEMINI_API_KEY"} + if Key("GEMINI_API_KEY") == "" { + key.Note += " not set" + key.NeedKey = true + } + out := []Option{ + {Value: "google", Label: "Google account", Icon: "gemini-color", Note: "OAuth sign-in"}, + key, + {Value: "vertex", Label: "Vertex AI", Icon: "googlecloud-color", Note: "Vertex AI · $GOOGLE_CLOUD_PROJECT"}, + } + if current() == "custom" { + out = append(out, Option{Value: "custom", Note: hostOf(base()) + " (from .env)"}) + } + return out + }, + }, + { + Key: "model", Label: "model", + Get: jsonGet(path, "model.name"), + Set: jsonSet(path, "model.name"), + Options: func(map[string]string) []Option { + var ms []catalog.Model + for _, m := range catalog.Provider("google") { + if strings.HasPrefix(m.ID, "gemini-") { + ms = append(ms, m) + } + } + return options(ms, "") + }, + }, + }, + } +} diff --git a/internal/agent/providers.go b/internal/agent/providers.go new file mode 100644 index 00000000..7598046b --- /dev/null +++ b/internal/agent/providers.go @@ -0,0 +1,655 @@ +package agent + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/yetone/dial/internal/catalog" +) + +// Provider is a model vendor an agent can be pointed at. Each agent speaks +// one wire protocol, so a provider is offered to an agent only when it has +// a base URL for that protocol. +// +// dial ships a list of vendors whose endpoints speak the protocols natively; +// the user can add their own, or edit and hide the built-in ones. Everything +// but the built-in defaults lives in ~/.config/dial/providers.json. +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + EnvKey string `json:"envKey"` // environment variable that carries the API key + Catalog string `json:"catalog,omitempty"` // models.dev provider id for the model list + Anthropic string `json:"anthropic,omitempty"` // base URL of an Anthropic Messages API (Claude Code) + Responses string `json:"responses,omitempty"` // base URL of an OpenAI Responses API (Codex) + Small string `json:"small,omitempty"` // model for the small/fast slot, when the vendor has one + Extra []string `json:"models,omitempty"` // the user's own model ids, on top of the catalog + Website string `json:"website,omitempty"` // the vendor's console + KeysURL string `json:"keysUrl,omitempty"` // where to create an API key + Icon string `json:"icon,omitempty"` // bundled icon name; empty means "derive one" + Builtin bool `json:"builtin"` // shipped with dial (may still be edited) +} + +// builtins are the vendors dial knows out of the box. Only endpoints that +// speak the protocol natively are listed; nothing here needs a proxy. +var builtins = []Provider{ + {ID: "deepseek", Name: "DeepSeek", EnvKey: "DEEPSEEK_API_KEY", Catalog: "deepseek", Icon: "deepseek-color", + Anthropic: "https://api.deepseek.com/anthropic", Responses: "https://api.deepseek.com/v1", Small: "deepseek-flash", + Website: "https://platform.deepseek.com", KeysURL: "https://platform.deepseek.com/api_keys"}, + {ID: "kimi", Name: "Kimi", EnvKey: "MOONSHOT_API_KEY", Catalog: "moonshotai", Icon: "kimi", + Anthropic: "https://api.moonshot.ai/anthropic", + Website: "https://platform.kimi.ai", KeysURL: "https://platform.kimi.ai/console/api-keys"}, + {ID: "kimi-cn", Name: "Kimi (China)", EnvKey: "MOONSHOT_API_KEY", Catalog: "moonshotai", Icon: "kimi", + Anthropic: "https://api.moonshot.cn/anthropic", + Website: "https://platform.kimi.com", KeysURL: "https://platform.kimi.com/console/api-keys"}, + {ID: "glm", Name: "Zhipu GLM", EnvKey: "ZHIPU_API_KEY", Catalog: "zhipuai", Icon: "zhipu-color", + Anthropic: "https://open.bigmodel.cn/api/anthropic", + Website: "https://open.bigmodel.cn", KeysURL: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"}, + {ID: "zai", Name: "Z.ai GLM", EnvKey: "ZAI_API_KEY", Catalog: "zhipuai", Icon: "zai", + Anthropic: "https://api.z.ai/api/anthropic", + Website: "https://z.ai", KeysURL: "https://z.ai/manage-apikey/apikey-list"}, + {ID: "minimax", Name: "MiniMax", EnvKey: "MINIMAX_API_KEY", Catalog: "minimax", Icon: "minimax-color", + Anthropic: "https://api.minimax.io/anthropic", + Website: "https://platform.minimax.io", KeysURL: "https://platform.minimax.io/user-center/basic-information/interface-key"}, + {ID: "minimax-cn", Name: "MiniMax (China)", EnvKey: "MINIMAX_API_KEY", Catalog: "minimax", Icon: "minimax-color", + Anthropic: "https://api.minimax.cn/anthropic", + Website: "https://platform.minimax.cn", KeysURL: "https://platform.minimax.cn/user-center/basic-information/interface-key"}, + {ID: "qwen", Name: "Qwen", EnvKey: "DASHSCOPE_API_KEY", Catalog: "alibaba", Icon: "qwen-color", + Anthropic: "https://dashscope-intl.aliyuncs.com/apps/anthropic", + Website: "https://modelstudio.console.alibabacloud.com", KeysURL: "https://modelstudio.console.alibabacloud.com/?tab=playground#/api-key"}, + {ID: "qwen-cn", Name: "Qwen (China)", EnvKey: "DASHSCOPE_API_KEY", Catalog: "alibaba", Icon: "qwen-color", + Anthropic: "https://dashscope.aliyuncs.com/apps/anthropic", + Website: "https://bailian.console.aliyun.com", KeysURL: "https://bailian.console.aliyun.com/?tab=model#/api-key"}, + {ID: "openrouter", Name: "OpenRouter", EnvKey: "OPENROUTER_API_KEY", Catalog: "openrouter", Icon: "openrouter", + Anthropic: "https://openrouter.ai/api", + Website: "https://openrouter.ai", KeysURL: "https://openrouter.ai/keys"}, +} + +// ---- registry --------------------------------------------------------------- + +type registry struct { + Providers []Provider `json:"providers"` // user-defined, and edited copies of built-ins + Hidden []string `json:"hidden,omitempty"` +} + +// ProvidersPath is the file that holds the user's providers. +func ProvidersPath() string { return filepath.Join(filepath.Dir(KeysPath()), "providers.json") } + +func loadRegistry() registry { + var r registry + if b, err := os.ReadFile(ProvidersPath()); err == nil { + json.Unmarshal(b, &r) + } + return r +} + +func saveRegistry(r registry) error { + p := ProvidersPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, append(b, '\n'), 0o644) +} + +// Providers is every provider in display order: the built-ins (with the +// user's edits, minus the hidden ones), then the user's own. +func Providers() []Provider { + r := loadRegistry() + edited := map[string]Provider{} + for _, p := range r.Providers { + edited[p.ID] = p + } + var out []Provider + for _, b := range builtins { + if contains(r.Hidden, b.ID) { + continue + } + if e, ok := edited[b.ID]; ok { + e.Builtin = true + if e.Icon == "" { + e.Icon = b.Icon + } + out = append(out, e) + continue + } + b.Builtin = true + out = append(out, b) + } + for _, p := range r.Providers { + if builtin(p.ID) == nil { + p.Builtin = false + out = append(out, p) + } + } + return out +} + +func builtin(id string) *Provider { + for i := range builtins { + if builtins[i].ID == id { + return &builtins[i] + } + } + return nil +} + +func provider(id string) *Provider { + for _, p := range Providers() { + if p.ID == id { + return &p + } + } + return nil +} + +// FindProvider looks a provider up by id. +func FindProvider(id string) (*Provider, error) { + if p := provider(strings.ToLower(strings.TrimSpace(id))); p != nil { + return p, nil + } + return nil, fmt.Errorf("unknown provider %q", id) +} + +var idRe = regexp.MustCompile(`[^a-z0-9]+`) + +// ProviderID derives an id from a display name: "My Proxy" → "my-proxy". +func ProviderID(name string) string { + return strings.Trim(idRe.ReplaceAllString(strings.ToLower(name), "-"), "-") +} + +// SaveProvider adds or updates a provider. A built-in gets an edited copy; +// the defaults stay available through ResetProvider. +func SaveProvider(p Provider) error { + p.ID = strings.TrimSpace(p.ID) + if p.ID == "" { + p.ID = ProviderID(p.Name) + } + if p.ID == "" || p.ID != ProviderID(p.ID) { + return fmt.Errorf("provider id must be lowercase letters, digits and dashes, not %q", p.ID) + } + if p.Name == "" { + p.Name = p.ID + } + p.EnvKey = strings.TrimSpace(p.EnvKey) + if p.EnvKey == "" { + p.EnvKey = strings.ToUpper(strings.ReplaceAll(p.ID, "-", "_")) + "_API_KEY" + } + if strings.ContainsAny(p.EnvKey, "= \n") { + return fmt.Errorf("bad variable name %q", p.EnvKey) + } + for _, u := range []*string{&p.Anthropic, &p.Responses, &p.Website, &p.KeysURL} { + *u = strings.TrimRight(strings.TrimSpace(*u), "/") + if *u != "" && !strings.HasPrefix(*u, "http://") && !strings.HasPrefix(*u, "https://") { + *u = "https://" + *u + } + } + if p.Anthropic == "" && p.Responses == "" { + return errors.New("a provider needs at least one endpoint: an Anthropic Messages URL (for Claude Code) or a Responses URL (for Codex)") + } + p.Extra = cleanModels(p.Extra) + p.Builtin = false + r := loadRegistry() + r.Hidden = without(r.Hidden, p.ID) + replaced := false + for i := range r.Providers { + if r.Providers[i].ID == p.ID { + r.Providers[i] = p + replaced = true + } + } + if !replaced { + if b := builtin(p.ID); b != nil && sameProvider(*b, p) { + return saveRegistry(r) // edited back to the defaults: nothing to keep + } + r.Providers = append(r.Providers, p) + } + return saveRegistry(r) +} + +// DeleteProvider removes a user provider, or hides a built-in one. +func DeleteProvider(id string) error { + r := loadRegistry() + var keep []Provider + found := false + for _, p := range r.Providers { + if p.ID == id { + found = true + continue + } + keep = append(keep, p) + } + r.Providers = keep + if builtin(id) != nil { + found = true + if !contains(r.Hidden, id) { + r.Hidden = append(r.Hidden, id) + } + } + if !found { + return fmt.Errorf("unknown provider %q", id) + } + return saveRegistry(r) +} + +// ResetProvider drops the user's edits to a built-in (and un-hides it). +func ResetProvider(id string) error { + if builtin(id) == nil { + return fmt.Errorf("%q is not a built-in provider", id) + } + r := loadRegistry() + var keep []Provider + for _, p := range r.Providers { + if p.ID != id { + keep = append(keep, p) + } + } + r.Providers = keep + r.Hidden = without(r.Hidden, id) + return saveRegistry(r) +} + +// HiddenProviders lists built-ins the user removed, so they can come back. +func HiddenProviders() []Provider { + var out []Provider + for _, id := range loadRegistry().Hidden { + if b := builtin(id); b != nil { + b.Builtin = true + out = append(out, *b) + } + } + return out +} + +func sameProvider(a, b Provider) bool { + a.Builtin, b.Builtin = false, false + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return bytes.Equal(x, y) +} + +func cleanModels(ms []string) []string { + var out []string + for _, m := range ms { + if m = strings.TrimSpace(m); m != "" && !contains(out, m) { + out = append(out, m) + } + } + return out +} + +func without(xs []string, x string) []string { + var out []string + for _, v := range xs { + if v != x { + out = append(out, v) + } + } + return out +} + +// providerByURL finds the provider whose base URL for the given protocol +// matches u (ignoring scheme and trailing slashes). +func providerByURL(u string, url func(Provider) string) *Provider { + n := normURL(u) + if n == "" { + return nil + } + for _, p := range Providers() { + if normURL(url(p)) == n { + return &p + } + } + return nil +} + +func normURL(u string) string { + u = strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(u), "https://"), "http://") + return strings.ToLower(strings.TrimRight(u, "/")) +} + +func hostOf(u string) string { + h := normURL(u) + if i := strings.Index(h, "/"); i > 0 { + h = h[:i] + } + return h +} + +// Host is the provider's API host, for display. +func (p Provider) Host() string { + if p.Anthropic != "" { + return hostOf(p.Anthropic) + } + return hostOf(p.Responses) +} + +// Models lists the provider's models: the user's own first, then what the +// vendor itself reported (see RefreshModels), else the catalog's, newest +// first, minus experiments. +func (p Provider) Models() []catalog.Model { + var out []catalog.Model + for _, id := range p.Extra { + out = append(out, catalog.Model{ID: id, Name: id}) + } + known := catalog.Provider(p.Catalog) + if live, _, ok := catalog.Live(p.ID); ok { + for _, m := range catalog.Decorate(live, known) { + if !contains(p.Extra, m.ID) { + out = append(out, m) + } + } + return out + } + for _, m := range known { + if !contains(p.Extra, m.ID) && !strings.Contains(m.ID, "-exp") && !strings.Contains(m.ID, "preview") { + out = append(out, m) + } + } + return out +} + +// LiveModels reports when the vendor's own list was last fetched. +func (p Provider) LiveModels() (n int, fetched time.Time, ok bool) { + ms, t, ok := catalog.Live(p.ID) + return len(ms), t, ok +} + +// RefreshModels asks the vendor which models it serves and remembers the +// answer, so pickers stop offering names the vendor retired. +func (p Provider) RefreshModels(ctx context.Context) ([]catalog.Model, error) { + key := Key(p.EnvKey) + if key == "" { + return nil, needKey(p) + } + var lastErr error + for _, base := range []string{p.Responses, p.Anthropic} { + if base == "" { + continue + } + ms, err := catalog.Fetch(ctx, base, key) + if err == nil { + return ms, catalog.SaveLive(p.ID, base, ms) + } + lastErr = err + } + return nil, lastErr +} + +// refreshQuietly is RefreshModels for the switch path: quick, and a failure +// just leaves the catalog in charge. +func (p Provider) refreshQuietly() { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + p.RefreshModels(ctx) +} + +// option renders the provider as a picker entry for the given base URL. +func (p Provider) option(base string) Option { + o := Option{Value: p.ID, Label: p.Name, Key: p.EnvKey, Icon: p.Icon, Note: hostOf(base) + " · $" + p.EnvKey} + if Key(p.EnvKey) == "" { + o.Note += " not set" + o.NeedKey = true + } + return o +} + +// sortReady puts providers whose key is present first, keeping order otherwise. +func sortReady(opts []Option) []Option { + sort.SliceStable(opts, func(i, j int) bool { return !opts[i].NeedKey && opts[j].NeedKey }) + return opts +} + +// ---- connectivity ----------------------------------------------------------- + +// TestResult is what a probe of one endpoint came back with. +type TestResult struct { + Protocol string `json:"protocol"` // "anthropic" or "responses" + OK bool `json:"ok"` + Status int `json:"status,omitempty"` + Millis int64 `json:"ms"` + Model string `json:"model,omitempty"` + Error string `json:"error,omitempty"` +} + +// Test sends the smallest possible request to each of the provider's +// endpoints with the key dial has, and reports what came back. +func (p Provider) Test(ctx context.Context) []TestResult { + key := Key(p.EnvKey) + if key != "" { + p.RefreshModels(ctx) + } + model := "" + if ms := p.Models(); len(ms) > 0 { + model = ms[0].ID + } + var out []TestResult + if p.Anthropic != "" { + body := fmt.Sprintf(`{"model":%q,"max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, model) + out = append(out, probe(ctx, "anthropic", p.Anthropic+"/v1/messages", body, model, map[string]string{ + "x-api-key": key, "Authorization": "Bearer " + key, "anthropic-version": "2023-06-01", + })) + } + if p.Responses != "" { + body := fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":16}`, model) + out = append(out, probe(ctx, "responses", p.Responses+"/responses", body, model, map[string]string{ + "Authorization": "Bearer " + key, + })) + } + return out +} + +func probe(ctx context.Context, proto, url, body, model string, headers map[string]string) TestResult { + r := TestResult{Protocol: proto, Model: model} + if model == "" { + r.Error = "no model to try: add one to the provider, or sync the catalog" + return r + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + if err != nil { + r.Error = err.Error() + return r + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + start := time.Now() + res, err := http.DefaultClient.Do(req) + r.Millis = time.Since(start).Milliseconds() + if err != nil { + r.Error = strings.TrimPrefix(err.Error(), "Post \""+url+"\": ") + return r + } + defer res.Body.Close() + r.Status = res.StatusCode + if res.StatusCode >= 200 && res.StatusCode < 300 { + r.OK = true + return r + } + b, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + r.Error = apiError(b, res.Status) + return r +} + +// apiError pulls the human message out of an error body when there is one. +func apiError(b []byte, fallback string) string { + var v struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + Message string `json:"message"` + } + if json.Unmarshal(b, &v) == nil { + if v.Error.Message != "" { + return v.Error.Message + } + if v.Message != "" { + return v.Message + } + } + if s := strings.TrimSpace(string(b)); s != "" && len(s) < 200 && !strings.HasPrefix(s, "<") { + return fallback + ": " + s + } + return fallback +} + +// ---- API keys -------------------------------------------------------------- +// +// Keys come from the environment first. For apps that do not inherit a +// shell (a desktop app, a GUI launcher) dial can hold a key itself, in a +// 0600 file next to its profiles, and writes it into the agent's config on +// switch. + +// KeysPath is the file dial keeps API keys in. +func KeysPath() string { + if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { + return filepath.Join(x, "dial", "keys") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "dial", "keys") +} + +// Key returns the API key for an env var: the environment, else dial's store. +func Key(env string) string { + if env == "" { + return "" + } + if v := os.Getenv(env); v != "" { + return v + } + return StoredKeys()[env] +} + +// KeyState says where a key comes from: "env", "stored" or "" (nowhere). +func KeyState(env string) (state, masked string) { + if env == "" { + return "", "" + } + if v := os.Getenv(env); v != "" { + return "env", Mask(v) + } + if v := StoredKeys()[env]; v != "" { + return "stored", Mask(v) + } + return "", "" +} + +// StoredKeys reads dial's key store. +func StoredKeys() map[string]string { + out := map[string]string{} + f, err := os.Open(KeysPath()) + if err != nil { + return out + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok && k != "" { + out[k] = v + } + } + return out +} + +// SetKey stores (or, with an empty value, forgets) a key. +func SetKey(env, value string) error { + env = strings.TrimSpace(env) + if env == "" || strings.ContainsAny(env, "= \n") { + return fmt.Errorf("bad variable name %q", env) + } + keys := StoredKeys() + if value == "" { + delete(keys, env) + } else { + keys[env] = strings.TrimSpace(value) + } + names := make([]string, 0, len(keys)) + for k := range keys { + names = append(names, k) + } + sort.Strings(names) + var b strings.Builder + for _, k := range names { + b.WriteString(k + "=" + keys[k] + "\n") + } + p := KeysPath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, []byte(b.String()), 0o600); err != nil { + return err + } + return os.Chmod(p, 0o600) +} + +// needKey is the error for a provider whose key is nowhere to be found. +func needKey(p Provider) error { + return fmt.Errorf("%s needs $%s: export it, or store it with `dial key %s `", p.Name, p.EnvKey, p.EnvKey) +} + +// Mask hides all but the ends of a secret. +func Mask(s string) string { + if len(s) <= 8 { + return strings.Repeat("•", len(s)) + } + return s[:4] + "…" + s[len(s)-4:] +} + +// ---- stash ----------------------------------------------------------------- +// +// Leaving a vendor's home provider replaces the model with one the new +// vendor knows. The old choice is kept here so the way back restores it. + +func stashPath() string { return filepath.Join(filepath.Dir(KeysPath()), "stash.json") } + +func stashLoad() map[string]string { + out := map[string]string{} + if b, err := os.ReadFile(stashPath()); err == nil { + json.Unmarshal(b, &out) + } + return out +} + +// stash remembers values under keys; empty values are dropped. +func stash(kv map[string]string) { + m := stashLoad() + for k, v := range kv { + if v == "" { + delete(m, k) + } else { + m[k] = v + } + } + b, _ := json.MarshalIndent(m, "", " ") + os.MkdirAll(filepath.Dir(stashPath()), 0o755) + os.WriteFile(stashPath(), b, 0o600) +} + +// unstash returns a remembered value and forgets it. +func unstash(key string) string { + m := stashLoad() + v := m[key] + if v != "" { + delete(m, key) + b, _ := json.MarshalIndent(m, "", " ") + os.WriteFile(stashPath(), b, 0o600) + } + return v +} diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go new file mode 100644 index 00000000..f3fdde3c --- /dev/null +++ b/internal/catalog/catalog.go @@ -0,0 +1,326 @@ +// Package catalog knows which models exist. It reads the models.dev catalog +// (from dial's own cache or OpenCode's), Codex's model cache, and falls back +// to a small built-in list so the picker is never empty. +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Model is one entry the picker can offer. +type Model struct { + ID string // e.g. "claude-sonnet-5" + Name string // display name + Provider string // models.dev provider id + Released string // YYYY-MM-DD, used for ordering + Efforts []string +} + +type mdProvider struct { + ID string `json:"id"` + Name string `json:"name"` + Env []string `json:"env"` + Models map[string]mdModel `json:"models"` +} + +type mdModel struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + Reasoning []struct { + Type string `json:"type"` + Values []string `json:"values"` + } `json:"reasoning_options"` + Modalities struct { + Output []string `json:"output"` + } `json:"modalities"` +} + +const modelsDevURL = "https://models.dev/api.json" + +var ( + once sync.Once + mdev map[string]mdProvider +) + +// CachePath is where `dial sync` stores the models.dev catalog. +func CachePath() string { + if x := os.Getenv("XDG_CACHE_HOME"); x != "" { + return filepath.Join(x, "dial", "models.json") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "dial", "models.json") +} + +func opencodeCache() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "opencode", "models.json") +} + +// Source reports which catalog file is in use ("" when only built-ins are). +func Source() string { + for _, p := range []string{CachePath(), opencodeCache()} { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func load() map[string]mdProvider { + once.Do(func() { + for _, p := range []string{CachePath(), opencodeCache()} { + b, err := os.ReadFile(p) + if err != nil { + continue + } + var m map[string]mdProvider + if json.Unmarshal(b, &m) == nil && len(m) > 0 { + mdev = m + return + } + } + }) + return mdev +} + +// Reset forgets the loaded catalog so the next call re-reads the cache. +func Reset() { + once = sync.Once{} + mdev = nil +} + +// Sync downloads the models.dev catalog into CachePath. +func Sync(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevURL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "dial") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("models.dev: %s", resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return err + } + var probe map[string]mdProvider + if err := json.Unmarshal(b, &probe); err != nil || len(probe) == 0 { + return fmt.Errorf("models.dev: unexpected payload") + } + p := CachePath() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + if err := os.WriteFile(p, b, 0o644); err != nil { + return err + } + Reset() + return nil +} + +// Stale reports whether no catalog exists or the cache is older than a week. +func Stale() bool { + src := Source() + if src == "" { + return true + } + st, err := os.Stat(src) + return err != nil || time.Since(st.ModTime()) > 7*24*time.Hour +} + +// ProviderEnv lists the env vars that unlock a models.dev provider. +func ProviderEnv(provider string) []string { + if p, ok := load()[provider]; ok { + return p.Env + } + return nil +} + +// ProviderAvailable is true when any of the provider's API-key env vars is set. +func ProviderAvailable(provider string) bool { + for _, e := range ProviderEnv(provider) { + if os.Getenv(e) != "" { + return true + } + } + return false +} + +// Provider returns the text models of one models.dev provider, newest first. +// Built-in models are merged in so the list is usable without any cache. +func Provider(id string) []Model { + seen := map[string]bool{} + var out []Model + if p, ok := load()[id]; ok { + for _, m := range p.Models { + if !textModel(m) { + continue + } + mm := Model{ID: m.ID, Name: m.Name, Provider: id, Released: m.ReleaseDate} + for _, r := range m.Reasoning { + if r.Type == "effort" { + mm.Efforts = r.Values + } + } + seen[m.ID] = true + out = append(out, mm) + } + } + for _, m := range builtin[id] { + if !seen[m.ID] { + m.Provider = id + out = append(out, m) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Released != out[j].Released { + return out[i].Released > out[j].Released + } + return out[i].ID < out[j].ID + }) + return out +} + +func textModel(m mdModel) bool { + if len(m.Modalities.Output) > 0 { + text := false + for _, o := range m.Modalities.Output { + if o == "text" { + text = true + } + } + if !text { + return false + } + } + id := m.ID + for _, bad := range []string{"embed", "-tts", "image", "audio", "-live", "robotics", "computer-use", "deep-research", "transcribe", "realtime", "moderation", "whisper", "dall-e", "sora"} { + if strings.Contains(id, bad) { + return false + } + } + return true +} + +// Providers returns models.dev provider ids known to the catalog. +func Providers() []string { + var ids []string + for id := range load() { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// Codex returns the models Codex itself lists (from its local cache). +func Codex() []Model { + home, _ := os.UserHomeDir() + b, err := os.ReadFile(filepath.Join(home, ".codex", "models_cache.json")) + if err != nil { + return builtin["codex"] + } + var cache struct { + Models []struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Visibility string `json:"visibility"` + Priority int `json:"priority"` + Levels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"models"` + } + if json.Unmarshal(b, &cache) != nil || len(cache.Models) == 0 { + return builtin["codex"] + } + sort.SliceStable(cache.Models, func(i, j int) bool { return cache.Models[i].Priority < cache.Models[j].Priority }) + var out []Model + for _, m := range cache.Models { + if m.Visibility == "hide" { + continue + } + mm := Model{ID: m.Slug, Name: m.DisplayName, Provider: "openai"} + for _, l := range m.Levels { + mm.Efforts = append(mm.Efforts, l.Effort) + } + out = append(out, mm) + } + return out +} + +// Efforts returns the reasoning levels a model supports, if known. +func Efforts(models []Model, id string) []string { + for _, m := range models { + if m.ID == id && len(m.Efforts) > 0 { + return m.Efforts + } + } + return nil +} + +// builtin keeps the picker useful with no catalog on disk. +var builtin = map[string][]Model{ + "anthropic": { + {ID: "claude-fable-5-1", Name: "Claude Fable 5.1", Released: "2026-09-01"}, + {ID: "claude-opus-5", Name: "Claude Opus 5", Released: "2026-07-01"}, + {ID: "claude-sonnet-5", Name: "Claude Sonnet 5", Released: "2026-07-01"}, + {ID: "claude-haiku-4-5-20251001", Name: "Claude Haiku 4.5", Released: "2025-10-01"}, + }, + "openai": { + {ID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"}, + {ID: "gpt-5.5", Name: "GPT-5.5"}, + {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"}, + }, + "codex": { + {ID: "gpt-6-astra", Name: "GPT-6-Astra", Efforts: []string{"low", "medium", "high", "xhigh", "max", "ultra"}}, + {ID: "gpt-5.6-sol", Name: "GPT-5.6-Sol", Efforts: []string{"low", "medium", "high", "xhigh"}}, + {ID: "gpt-5.6-terra", Name: "GPT-5.6-Terra", Efforts: []string{"low", "medium", "high", "xhigh"}}, + {ID: "gpt-5.6-luna", Name: "GPT-5.6-Luna", Efforts: []string{"low", "medium", "high", "xhigh"}}, + {ID: "gpt-5.5", Name: "GPT-5.5", Efforts: []string{"low", "medium", "high", "xhigh"}}, + }, + "deepseek": { + {ID: "deepseek-flash", Name: "DeepSeek V4.1 Flash", Released: "2026-09-10", Efforts: []string{"low", "high", "max"}}, + {ID: "deepseek-v4-pro", Name: "DeepSeek V4 Pro", Released: "2026-08-12", Efforts: []string{"low", "high", "max"}}, + }, + "google": { + {ID: "gemini-3.1-pro", Name: "Gemini 3.1 Pro"}, + {ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"}, + {ID: "gemini-2.5-pro", Name: "Gemini 2.5 Pro"}, + {ID: "gemini-2.5-flash", Name: "Gemini 2.5 Flash"}, + }, + "copilot": { + {ID: "auto", Name: "Let Copilot pick"}, + {ID: "claude-fable-5", Name: "Claude Fable 5"}, + {ID: "claude-opus-4.8", Name: "Claude Opus 4.8"}, + {ID: "claude-sonnet-4.5", Name: "Claude Sonnet 4.5"}, + {ID: "claude-haiku-4.5", Name: "Claude Haiku 4.5"}, + {ID: "gpt-5.6-sol", Name: "GPT-5.6 Sol"}, + {ID: "gpt-5.6-terra", Name: "GPT-5.6 Terra"}, + {ID: "gpt-5.6-luna", Name: "GPT-5.6 Luna"}, + {ID: "gpt-5.5", Name: "GPT-5.5"}, + {ID: "gpt-5.4", Name: "GPT-5.4"}, + {ID: "gpt-5.4-mini", Name: "GPT-5.4 mini"}, + {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"}, + {ID: "gemini-3.6-flash", Name: "Gemini 3.6 Flash"}, + {ID: "gemini-3.5-flash", Name: "Gemini 3.5 Flash"}, + }, +} + +// Builtin exposes a built-in list by name (e.g. "copilot"). +func Builtin(name string) []Model { return builtin[name] } diff --git a/internal/catalog/live.go b/internal/catalog/live.go new file mode 100644 index 00000000..9a17272f --- /dev/null +++ b/internal/catalog/live.go @@ -0,0 +1,185 @@ +package catalog + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// A vendor's own /models endpoint is the truth about what it serves today; +// models.dev lags and keeps legacy names around. dial asks the vendor when +// it has a key, remembers the answer next to the models.dev cache, and lets +// the catalog fill in display names and reasoning levels. + +// LivePath is where the fetched model list of one provider is kept. +func LivePath(provider string) string { + return filepath.Join(filepath.Dir(CachePath()), "models", provider+".json") +} + +type liveFile struct { + Fetched time.Time `json:"fetched"` + Base string `json:"base"` + Models []Model `json:"models"` +} + +// Live returns the model list last fetched from the provider, if any. +func Live(provider string) (models []Model, fetched time.Time, ok bool) { + b, err := os.ReadFile(LivePath(provider)) + if err != nil { + return nil, time.Time{}, false + } + var f liveFile + if json.Unmarshal(b, &f) != nil || len(f.Models) == 0 { + return nil, time.Time{}, false + } + return f.Models, f.Fetched, true +} + +// SaveLive stores a fetched list; an empty list forgets it. +func SaveLive(provider, base string, models []Model) error { + p := LivePath(provider) + if len(models) == 0 { + err := os.Remove(p) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + b, _ := json.MarshalIndent(liveFile{Fetched: time.Now(), Base: base, Models: models}, "", " ") + return os.WriteFile(p, b, 0o644) +} + +// Fetch asks an endpoint for its models. base is an API base URL of any +// flavour (…/v1, …/anthropic, …/api); the usual list paths are tried +// around it. The result keeps the server's order. +func Fetch(ctx context.Context, base, key string) ([]Model, error) { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return nil, errors.New("no base URL") + } + var urls []string + add := func(u string) { + for _, x := range urls { + if x == u { + return + } + } + urls = append(urls, u) + } + add(base + "/models") + add(base + "/v1/models") + root := base + for _, suffix := range []string{"/anthropic", "/apps/anthropic", "/api/anthropic", "/v1", "/api", "/api/v1"} { + if strings.HasSuffix(root, suffix) { + root = strings.TrimSuffix(root, suffix) + } + } + add(root + "/v1/models") + add(root + "/models") + + var lastErr error + for _, u := range urls { + ms, err := fetchOne(ctx, u, key) + if err == nil && len(ms) > 0 { + return ms, nil + } + if err != nil { + lastErr = err + } + if ctx.Err() != nil { + break + } + } + if lastErr == nil { + lastErr = errors.New("no model list at " + base) + } + return nil, lastErr +} + +func fetchOne(ctx context.Context, url, key string) ([]Model, error) { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "dial") + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("x-api-key", key) + req.Header.Set("anthropic-version", "2023-06-01") + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 8<<20)) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, res.Status) + } + var v struct { + Data []liveModel `json:"data"` + Models []liveModel `json:"models"` + } + if err := json.Unmarshal(b, &v); err != nil { + return nil, fmt.Errorf("%s: not a model list", url) + } + rows := v.Data + if len(rows) == 0 { + rows = v.Models + } + var out []Model + for _, r := range rows { + id := r.ID + if id == "" { + id = r.Name + } + if id == "" || !textModel(mdModel{ID: id}) { + continue + } + name := r.DisplayName + if name == "" { + name = id + } + out = append(out, Model{ID: id, Name: name}) + } + return out, nil +} + +type liveModel struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` +} + +// Decorate fills in names and reasoning levels for live models from the +// catalog's entry for the same id, keeping the live order. +func Decorate(live []Model, known []Model) []Model { + byID := make(map[string]Model, len(known)) + for _, m := range known { + byID[m.ID] = m + } + out := make([]Model, 0, len(live)) + for _, m := range live { + if k, ok := byID[m.ID]; ok { + if m.Name == "" || m.Name == m.ID { + m.Name = k.Name + } + m.Efforts, m.Released, m.Provider = k.Efforts, k.Released, k.Provider + } + out = append(out, m) + } + return out +} diff --git a/internal/edit/del_test.go b/internal/edit/del_test.go new file mode 100644 index 00000000..16baedf6 --- /dev/null +++ b/internal/edit/del_test.go @@ -0,0 +1,82 @@ +package edit + +import "testing" + +func TestDelJSONMiddleKeepsLayout(t *testing.T) { + in := "{\n \"env\": {\n \"A\": \"1\",\n \"B\": \"2\",\n \"C\": \"3\"\n },\n \"model\": \"m\"\n}\n" + p := tmpFile(t, "s.json", in) + if err := DelJSON(p, "env.B"); err != nil { + t.Fatal(err) + } + want := "{\n \"env\": {\n \"A\": \"1\",\n \"C\": \"3\"\n },\n \"model\": \"m\"\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } + // last member: preceding comma goes + if err := DelJSON(p, "env.C", "nope.x"); err != nil { + t.Fatal(err) + } + want = "{\n \"env\": {\n \"A\": \"1\"\n },\n \"model\": \"m\"\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } + // only member: parent collapses to {} + if err := DelJSON(p, "env.A"); err != nil { + t.Fatal(err) + } + want = "{\n \"env\": {},\n \"model\": \"m\"\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } + // top-level last member + if err := DelJSON(p, "model"); err != nil { + t.Fatal(err) + } + want = "{\n \"env\": {}\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got\n%s\nwant\n%s", got, want) + } +} + +func TestDelJSONCompactAndComments(t *testing.T) { + p := tmpFile(t, "c.jsonc", "{\"a\":1,\"b\":{\"x\":true},\"c\":3}") + if err := DelJSON(p, "a", "b.x"); err != nil { + t.Fatal(err) + } + if got := read(t, p); got != "{\"b\":{},\"c\":3}" { + t.Fatalf("got %q", got) + } + p = tmpFile(t, "d.jsonc", "{\n // keep me\n \"a\": 1, // trailing\n \"b\": 2\n}\n") + if err := DelJSON(p, "b"); err != nil { + t.Fatal(err) + } + if got := read(t, p); got != "{\n // keep me\n \"a\": 1 // trailing\n}\n" { + t.Fatalf("got %q", got) + } +} + +func TestEnvFile(t *testing.T) { + p := tmpFile(t, ".env", "# comment\nFOO=bar\nexport BAZ=\"q x\"\n") + if v, ok := GetEnvFile(p, "BAZ"); !ok || v != "q x" { + t.Fatalf("got %q %v", v, ok) + } + if err := SetEnvFile(p, KV{"FOO", "new"}, KV{"KEY", "sk-1"}); err != nil { + t.Fatal(err) + } + if got := read(t, p); got != "# comment\nFOO=new\nexport BAZ=\"q x\"\nKEY=sk-1\n" { + t.Fatalf("got %q", got) + } + if err := DelEnvFile(p, "FOO", "KEY"); err != nil { + t.Fatal(err) + } + if got := read(t, p); got != "# comment\nexport BAZ=\"q x\"\n" { + t.Fatalf("got %q", got) + } + np := tmpFile(t, "new.env", "") + if err := SetEnvFile(np, KV{"A", "1"}); err != nil { + t.Fatal(err) + } + if got := read(t, np); got != "A=1\n" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/edit/edit_test.go b/internal/edit/edit_test.go new file mode 100644 index 00000000..c542171a --- /dev/null +++ b/internal/edit/edit_test.go @@ -0,0 +1,190 @@ +package edit + +import ( + "os" + "path/filepath" + "testing" +) + +func tmpFile(t *testing.T, name, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if content != "" || name == "empty.json" { + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return p +} + +func read(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestJSONReplaceKeepsEverything(t *testing.T) { + in := "{\n \"env\": {\n \"X\": \"1\"\n },\n \"model\": \"old\",\n \"hooks\": []\n}\n" + p := tmpFile(t, "settings.json", in) + if err := SetJSON(p, KV{Path: "model", Value: "new"}); err != nil { + t.Fatal(err) + } + want := "{\n \"env\": {\n \"X\": \"1\"\n },\n \"model\": \"new\",\n \"hooks\": []\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + if v, ok := GetJSON(p, "model"); !ok || v != "new" { + t.Fatalf("GetJSON = %q, %v", v, ok) + } +} + +func TestJSONInsertTopLevel(t *testing.T) { + in := "{\n \"a\": 1\n}\n" + p := tmpFile(t, "settings.json", in) + if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil { + t.Fatal(err) + } + want := "{\n \"model\": \"m\",\n \"a\": 1\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestJSONInsertNestedIntoExistingObject(t *testing.T) { + in := "{\n \"model\": {\n \"other\": true\n }\n}\n" + p := tmpFile(t, "settings.json", in) + if err := SetJSON(p, KV{Path: "model.name", Value: "gemini-2.5-pro"}); err != nil { + t.Fatal(err) + } + want := "{\n \"model\": {\n \"name\": \"gemini-2.5-pro\",\n \"other\": true\n }\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestJSONInsertNestedCreatesParents(t *testing.T) { + in := "{\n \"ui\": {}\n}\n" + p := tmpFile(t, "settings.json", in) + if err := SetJSON(p, KV{Path: "models.large.model", Value: "x"}); err != nil { + t.Fatal(err) + } + want := "{\n \"models\": {\n \"large\": {\n \"model\": \"x\"\n }\n },\n \"ui\": {}\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestJSONScalarParentBecomesObject(t *testing.T) { + in := "{\n \"model\": \"gemini-2.5-pro\"\n}\n" + p := tmpFile(t, "settings.json", in) + if err := SetJSON(p, KV{Path: "model.name", Value: "gemini-3-pro"}); err != nil { + t.Fatal(err) + } + want := "{\n \"model\": {\n \"name\": \"gemini-3-pro\"\n }\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestJSONCKeepsComments(t *testing.T) { + in := "{\n // schema\n \"$schema\": \"https://opencode.ai/config.json\", /* x */\n \"model\": \"a/b\"\n}\n" + p := tmpFile(t, "opencode.jsonc", in) + if err := SetJSON(p, KV{Path: "model", Value: "anthropic/claude-sonnet-5"}, KV{Path: "small_model", Value: "anthropic/claude-haiku-4-5"}); err != nil { + t.Fatal(err) + } + want := "{\n \"small_model\": \"anthropic/claude-haiku-4-5\",\n // schema\n \"$schema\": \"https://opencode.ai/config.json\", /* x */\n \"model\": \"anthropic/claude-sonnet-5\"\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + if v, _ := GetJSON(p, "small_model"); v != "anthropic/claude-haiku-4-5" { + t.Fatalf("GetJSON = %q", v) + } +} + +func TestJSONEmptyAndMissingFile(t *testing.T) { + p := tmpFile(t, "empty.json", "") + if err := SetJSON(p, KV{Path: "model", Value: "m"}, KV{Path: "flag", Value: true}); err != nil { + t.Fatal(err) + } + if got, want := read(t, p), "{\n \"flag\": true,\n \"model\": \"m\"\n}\n"; got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + p2 := filepath.Join(t.TempDir(), "sub", "new.json") + if err := SetJSON(p2, KV{Path: "model", Value: "m"}); err != nil { + t.Fatal(err) + } + if v, _ := GetJSON(p2, "model"); v != "m" { + t.Fatalf("missing file: got %q", v) + } +} + +func TestJSONCompact(t *testing.T) { + p := tmpFile(t, "c.json", `{"a":1}`) + if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil { + t.Fatal(err) + } + if got, want := read(t, p), `{"model":"m","a":1}`; got != want { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestJSONBracesInStrings(t *testing.T) { + in := "{\n \"cmd\": \"echo {\\\"x\\\": 1}\"\n}\n" + p := tmpFile(t, "s.json", in) + if err := SetJSON(p, KV{Path: "model", Value: "m"}); err != nil { + t.Fatal(err) + } + want := "{\n \"model\": \"m\",\n \"cmd\": \"echo {\\\"x\\\": 1}\"\n}\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestTOMLTop(t *testing.T) { + in := "model_reasoning_effort = \"medium\"\nmodel = \"old\" # comment\n\n[projects.\"/x\"]\nmodel = \"not-top\"\n" + p := tmpFile(t, "config.toml", in) + if v, ok := GetTOMLTop(p, "model"); !ok || v != "old" { + t.Fatalf("get = %q %v", v, ok) + } + if err := SetTOMLTop(p, KV{Path: "model", Value: "gpt-5.5"}, KV{Path: "model_provider", Value: "openai"}); err != nil { + t.Fatal(err) + } + want := "model_reasoning_effort = \"medium\"\nmodel = \"gpt-5.5\"\nmodel_provider = \"openai\"\n\n[projects.\"/x\"]\nmodel = \"not-top\"\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + if v, _ := GetTOMLTop(p, "model"); v != "gpt-5.5" { + t.Fatalf("get after set = %q", v) + } +} + +func TestTOMLMissingFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.toml") + if err := SetTOMLTop(p, KV{Path: "model", Value: "m"}); err != nil { + t.Fatal(err) + } + if got, want := read(t, p), "model = \"m\"\n"; got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestYAMLTop(t *testing.T) { + in := "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-3-7 # old\nextensions:\n developer:\n enabled: true\n" + p := tmpFile(t, "config.yaml", in) + if v, _ := GetYAMLTop(p, "GOOSE_MODEL"); v != "claude-3-7" { + t.Fatalf("get = %q", v) + } + if err := SetYAMLTop(p, KV{Path: "GOOSE_PROVIDER", Value: "openrouter"}, KV{Path: "GOOSE_MODEL", Value: "z-ai/glm-5.2:batch"}); err != nil { + t.Fatal(err) + } + want := "GOOSE_PROVIDER: openrouter\nGOOSE_MODEL: \"z-ai/glm-5.2:batch\"\nextensions:\n developer:\n enabled: true\n" + if got := read(t, p); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + if v, _ := GetYAMLTop(p, "GOOSE_MODEL"); v != "z-ai/glm-5.2:batch" { + t.Fatalf("get after set = %q", v) + } +} diff --git a/internal/edit/env.go b/internal/edit/env.go new file mode 100644 index 00000000..9d5d0c47 --- /dev/null +++ b/internal/edit/env.go @@ -0,0 +1,75 @@ +package edit + +import ( + "regexp" + "strings" +) + +// dotenv files (KEY=value per line) as Gemini CLI reads from ~/.gemini/.env. + +var envLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$`) + +// GetEnvFile reads one key from a dotenv file. +func GetEnvFile(path, key string) (string, bool) { + raw, err := Read(path) + if err != nil { + return "", false + } + for _, l := range splitLines(string(raw)) { + if m := envLine.FindStringSubmatch(l); m != nil && m[1] == key { + if v, ok := unquote(m[2]); ok { + return v, true + } + return m[2], true + } + } + return "", false +} + +// SetEnvFile sets keys in a dotenv file, replacing lines in place and +// appending new ones. Other lines are kept byte for byte. +func SetEnvFile(path string, kvs ...KV) error { + raw, err := Read(path) + if err != nil { + return err + } + lines := splitLines(string(raw)) + for _, kv := range kvs { + v := toString(kv.Value) + if strings.ContainsAny(v, " #\"'$") { + v = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"` + } + lines = setLine(lines, kv.Path, kv.Path+"="+v, nil, func(l string) (string, bool) { + if m := envLine.FindStringSubmatch(l); m != nil { + return m[1], true + } + return "", false + }) + } + return WriteAtomic(path, []byte(joinLines(lines))) +} + +// DelEnvFile removes keys from a dotenv file. +func DelEnvFile(path string, keys ...string) error { + raw, err := Read(path) + if err != nil || len(raw) == 0 { + return err + } + drop := map[string]bool{} + for _, k := range keys { + drop[k] = true + } + var out []string + changed := false + for _, l := range splitLines(string(raw)) { + if m := envLine.FindStringSubmatch(l); m != nil && drop[m[1]] { + changed = true + continue + } + out = append(out, l) + } + if !changed { + return nil + } + return WriteAtomic(path, []byte(joinLines(out))) +} diff --git a/internal/edit/file.go b/internal/edit/file.go new file mode 100644 index 00000000..8f410110 --- /dev/null +++ b/internal/edit/file.go @@ -0,0 +1,58 @@ +// Package edit provides format-preserving editors for the config files that +// coding agents keep: JSON/JSONC, TOML and YAML. Only the requested key +// changes; comments, ordering and indentation are left as they are. +package edit + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +// Read returns the file contents, or (nil, nil) when the file does not exist. +func Read(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return b, err +} + +// WriteAtomic writes data to path via a temp file + rename so a crash can +// never leave a half-written config behind. File mode is preserved. +func WriteAtomic(path string, data []byte) error { + mode := fs.FileMode(0o644) + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + cleanup() + return err + } + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Rename(tmpName, path); err != nil { + cleanup() + return err + } + return nil +} diff --git a/internal/edit/json.go b/internal/edit/json.go new file mode 100644 index 00000000..60eef74e --- /dev/null +++ b/internal/edit/json.go @@ -0,0 +1,287 @@ +package edit + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/jsonc" +) + +// KV is one key path (dot-separated, e.g. "model.name") and its new value. +type KV struct { + Path string + Value any +} + +// GetJSON reads a dot-separated key path from a JSON or JSONC file. +func GetJSON(path, keyPath string) (string, bool) { + raw, err := Read(path) + if err != nil || len(raw) == 0 { + return "", false + } + r := gjson.GetBytes(jsonc.ToJSONInPlace(raw), keyPath) + if !r.Exists() { + return "", false + } + return r.String(), true +} + +// SetJSON sets one or more key paths in a JSON/JSONC file, preserving +// comments, key order and indentation. Missing files and parents are created. +func SetJSON(path string, kvs ...KV) error { + raw, err := Read(path) + if err != nil { + return err + } + if len(bytes.TrimSpace(raw)) == 0 { + raw = []byte("{}\n") + } + for _, kv := range kvs { + raw, err = setJSONBytes(raw, kv.Path, kv.Value) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return WriteAtomic(path, raw) +} + +func setJSONBytes(raw []byte, keyPath string, value any) ([]byte, error) { + // Comments are blanked out in a copy of identical length so gjson offsets + // map 1:1 back onto the original bytes. + stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...)) + root := gjson.ParseBytes(stripped) + if !root.IsObject() { + return nil, fmt.Errorf("top level is not a JSON object") + } + parts := strings.Split(keyPath, ".") + for i := len(parts); i >= 1; i-- { + r := gjson.GetBytes(stripped, strings.Join(parts[:i], ".")) + if !r.Exists() { + continue + } + if i == len(parts) || !r.IsObject() { + // Exact key exists (replace value), or an ancestor exists but is a + // scalar (replace it with the nested object we need). + return splice(raw, r.Index, len(r.Raw), marshalAt(raw, r.Index, nest(parts[i:], value))) + } + return insertKey(raw, stripped, r.Index, parts[i], nest(parts[i+1:], value)) + } + return insertKey(raw, stripped, 0, parts[0], nest(parts[1:], value)) +} + +func nest(parts []string, value any) any { + for i := len(parts) - 1; i >= 0; i-- { + value = map[string]any{parts[i]: value} + } + return value +} + +func splice(raw []byte, at, n int, with []byte) ([]byte, error) { + out := make([]byte, 0, len(raw)-n+len(with)) + out = append(out, raw[:at]...) + out = append(out, with...) + out = append(out, raw[at+n:]...) + return out, nil +} + +// insertKey adds key: value as the first member of the object that starts +// at or after position from. +func insertKey(raw, stripped []byte, from int, key string, value any) ([]byte, error) { + open := from + for open < len(raw) && raw[open] != '{' { + open++ + } + close := matchBrace(stripped, open) + if open >= len(raw) || close < 0 { + return nil, fmt.Errorf("cannot locate object for %q", key) + } + k, _ := json.Marshal(key) + pretty := bytes.IndexByte(raw, '\n') >= 0 + empty := len(bytes.TrimSpace(raw[open+1:close])) == 0 + + if !pretty { + v, _ := json.Marshal(value) + ins := string(k) + ":" + string(v) + if !empty { + ins += "," + } + return splice(raw, open+1, 0, []byte(ins)) + } + + base := lineIndent(raw, open) + unit := indentUnit(raw, open, base) + child := base + unit + v, _ := json.MarshalIndent(value, child, unit) + kv := child + string(k) + ": " + string(v) + if empty { + return splice(raw, open, close-open+1, []byte("{\n"+kv+"\n"+base+"}")) + } + return splice(raw, open+1, 0, []byte("\n"+kv+",")) +} + +// marshalAt renders value with indentation matching the line that holds pos. +func marshalAt(raw []byte, pos int, value any) []byte { + if bytes.IndexByte(raw, '\n') < 0 { + b, _ := json.Marshal(value) + return b + } + base := lineIndent(raw, pos) + b, _ := json.MarshalIndent(value, base, indentUnit(raw, pos, base)) + return b +} + +func lineIndent(raw []byte, pos int) string { + start := bytes.LastIndexByte(raw[:pos], '\n') + 1 + i := start + for i < len(raw) && (raw[i] == ' ' || raw[i] == '\t') { + i++ + } + return string(raw[start:i]) +} + +// indentUnit guesses the indentation step from the line following pos, +// falling back to two spaces (or a tab when the file uses tabs). +func indentUnit(raw []byte, pos int, base string) string { + if nl := bytes.IndexByte(raw[pos:], '\n'); nl >= 0 { + next := lineIndent(raw, pos+nl+1) + if len(next) > len(base) && strings.HasPrefix(next, base) { + return next[len(base):] + } + } + if strings.Contains(base, "\t") || bytes.Contains(raw, []byte("\n\t")) { + return "\t" + } + return " " +} + +// matchBrace returns the index of the '}' closing the '{' at open, skipping +// over string literals. It expects comment-free input. +func matchBrace(b []byte, open int) int { + depth := 0 + inStr := false + for i := open; i < len(b); i++ { + c := b[i] + switch { + case inStr: + if c == '\\' { + i++ + } else if c == '"' { + inStr = false + } + case c == '"': + inStr = true + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// DelJSON removes key paths from a JSON/JSONC file, preserving everything +// else. Missing keys are ignored; an object left empty collapses to {}. +func DelJSON(path string, keyPaths ...string) error { + raw, err := Read(path) + if err != nil || len(bytes.TrimSpace(raw)) == 0 { + return err + } + changed := false + for _, kp := range keyPaths { + out, ok, err := delJSONBytes(raw, kp) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if ok { + raw, changed = out, true + } + } + if !changed { + return nil + } + return WriteAtomic(path, raw) +} + +func delJSONBytes(raw []byte, keyPath string) ([]byte, bool, error) { + stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...)) + r := gjson.GetBytes(stripped, keyPath) + if !r.Exists() || r.Index == 0 { + return raw, false, nil + } + // value span → back over ':' and whitespace to the key string + a := r.Index + for a > 0 && (stripped[a-1] == ' ' || stripped[a-1] == '\t' || stripped[a-1] == '\n' || stripped[a-1] == '\r' || stripped[a-1] == ':') { + a-- + } + if a == 0 || stripped[a-1] != '"' { + return nil, false, fmt.Errorf("cannot locate key %q", keyPath) + } + a-- // closing quote of the key + for a > 0 && stripped[a-1] != '"' { + a-- + } + a-- // opening quote + b := r.Index + len(r.Raw) + + ws := func(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } + // the member's own span: whole line when it sits alone on one + from, to := a, b + ls := bytes.LastIndexByte(raw[:a], '\n') + 1 + alone := len(bytes.TrimSpace(raw[ls:a])) == 0 + j := b + for j < len(stripped) && ws(stripped[j]) { + j++ + } + var out []byte + if j < len(stripped) && stripped[j] == ',' { + // not the last member: drop through the trailing comma + to = j + 1 + if alone { + from = ls + if to < len(raw) && raw[to] == '\n' { + to++ + } + } + out, _ = splice(raw, from, to-from, nil) + } else { + // last member: drop the line, then the comma that preceded it + if alone { + from = ls + if to < len(raw) && raw[to] == '\n' { + to++ + } + } + out, _ = splice(raw, from, to-from, nil) + k := a + for k > 0 && ws(stripped[k-1]) { + k-- + } + if k > 0 && stripped[k-1] == ',' { + out, _ = splice(out, k-1, 1, nil) + } + } + + // collapse a now-empty parent object to {} + s2 := jsonc.ToJSONInPlace(append([]byte(nil), out...)) + parent := gjson.ParseBytes(s2) + if i := strings.LastIndex(keyPath, "."); i >= 0 { + parent = gjson.GetBytes(s2, keyPath[:i]) + } + if parent.IsObject() && len(parent.Map()) == 0 { + open := parent.Index + for open < len(s2) && s2[open] != '{' { + open++ + } + close := matchBrace(s2, open) + if close > open && len(bytes.TrimSpace(out[open+1:close])) == 0 { + out, _ = splice(out, open, close-open+1, []byte("{}")) + } + } + return out, true, nil +} diff --git a/internal/edit/text.go b/internal/edit/text.go new file mode 100644 index 00000000..b34dc432 --- /dev/null +++ b/internal/edit/text.go @@ -0,0 +1,192 @@ +package edit + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Top-level TOML and YAML keys are edited line by line. That keeps every +// comment and every other line byte-for-byte intact, which a round trip +// through a parser would not. + +var ( + tomlTable = regexp.MustCompile(`^\s*\[`) + tomlKV = regexp.MustCompile(`^\s*([A-Za-z0-9_.-]+|"[^"]*")\s*=\s*(.*?)\s*$`) + yamlKV = regexp.MustCompile(`^([A-Za-z0-9_.-]+)\s*:\s*(.*?)\s*$`) + yamlPlain = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) +) + +// GetTOMLTop reads a top-level (pre-table) key from a TOML file. +func GetTOMLTop(path, key string) (string, bool) { + raw, err := Read(path) + if err != nil || raw == nil { + return "", false + } + for _, line := range splitLines(string(raw)) { + if tomlTable.MatchString(line) { + break + } + if m := tomlKV.FindStringSubmatch(line); m != nil && strings.Trim(m[1], `"`) == key { + return tomlValue(m[2]), true + } + } + return "", false +} + +// SetTOMLTop sets top-level keys in a TOML file. Existing lines are replaced +// in place; new keys go right after the last existing top-level key. +func SetTOMLTop(path string, kvs ...KV) error { + raw, err := Read(path) + if err != nil { + return err + } + lines := splitLines(string(raw)) + for _, kv := range kvs { + lines = setLine(lines, kv.Path, kv.Path+" = "+strconv.Quote(toString(kv.Value)), tomlTable, func(line string) (string, bool) { + m := tomlKV.FindStringSubmatch(line) + if m == nil { + return "", false + } + return strings.Trim(m[1], `"`), true + }) + } + return WriteAtomic(path, []byte(joinLines(lines))) +} + +// GetYAMLTop reads a top-level scalar key from a YAML file. +func GetYAMLTop(path, key string) (string, bool) { + raw, err := Read(path) + if err != nil || raw == nil { + return "", false + } + for _, line := range splitLines(string(raw)) { + if m := yamlKV.FindStringSubmatch(line); m != nil && m[1] == key { + return yamlValue(m[2]), true + } + } + return "", false +} + +// SetYAMLTop sets top-level scalar keys in a YAML file. +func SetYAMLTop(path string, kvs ...KV) error { + raw, err := Read(path) + if err != nil { + return err + } + lines := splitLines(string(raw)) + for _, kv := range kvs { + v := toString(kv.Value) + if !yamlPlain.MatchString(v) { + v = strconv.Quote(v) + } + lines = setLine(lines, kv.Path, kv.Path+": "+v, nil, func(line string) (string, bool) { + m := yamlKV.FindStringSubmatch(line) + if m == nil { + return "", false + } + return m[1], true + }) + } + return WriteAtomic(path, []byte(joinLines(lines))) +} + +// setLine replaces the line whose key matches, or inserts newLine after the +// last key line in the header section (before the first line matching stop). +func setLine(lines []string, key, newLine string, stop *regexp.Regexp, keyOf func(string) (string, bool)) []string { + lastKey := -1 + for i, line := range lines { + if stop != nil && stop.MatchString(line) { + break + } + k, ok := keyOf(line) + if !ok { + continue + } + if k == key { + lines[i] = newLine + return lines + } + lastKey = i + } + at := lastKey + 1 + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:at]...) + out = append(out, newLine) + out = append(out, lines[at:]...) + return out +} + +// unquote strips a leading quoted scalar (and anything after it, such as an +// inline comment); ok is false when v does not start with a quote. +func unquote(v string) (string, bool) { + if v == "" || (v[0] != '"' && v[0] != '\'') { + return "", false + } + q := v[0] + for i := 1; i < len(v); i++ { + if q == '"' && v[i] == '\\' { + i++ + continue + } + if v[i] == q { + if q == '"' { + if s, err := strconv.Unquote(v[:i+1]); err == nil { + return s, true + } + } + return v[1:i], true + } + } + return v[1:], true +} + +func tomlValue(v string) string { + if s, ok := unquote(v); ok { + return s + } + if i := strings.Index(v, "#"); i >= 0 { + v = strings.TrimSpace(v[:i]) + } + return v +} + +func yamlValue(v string) string { + if s, ok := unquote(v); ok { + return s + } + if i := strings.Index(v, " #"); i >= 0 { + v = strings.TrimSpace(v[:i]) + } + return v +} + +func toString(v any) string { + switch x := v.(type) { + case string: + return x + case bool: + return strconv.FormatBool(x) + case int: + return strconv.Itoa(x) + } + return fmt.Sprint(v) +} + +// splitLines splits on '\n' but keeps a trailing newline as an empty final +// element so joinLines can restore the file exactly. +func splitLines(s string) []string { + if s == "" { + return []string{""} + } + return strings.Split(s, "\n") +} + +func joinLines(lines []string) string { + s := strings.Join(lines, "\n") + if !strings.HasSuffix(s, "\n") { + s += "\n" + } + return s +} diff --git a/internal/edit/toml.go b/internal/edit/toml.go new file mode 100644 index 00000000..2c34f595 --- /dev/null +++ b/internal/edit/toml.go @@ -0,0 +1,167 @@ +package edit + +import ( + "regexp" + "strconv" + "strings" +) + +// Whole TOML tables are treated as units: dial owns the tables it writes +// (for example a model provider) and replaces them verbatim, while every +// other line of the file stays untouched. + +var tomlHeader = regexp.MustCompile(`^\s*\[\s*([^\]]+?)\s*\]`) + +// TOMLTables lists the table headers of a TOML file in order. +func TOMLTables(path string) []string { + raw, err := Read(path) + if err != nil || raw == nil { + return nil + } + var out []string + for _, line := range splitLines(string(raw)) { + if m := tomlHeader.FindStringSubmatch(line); m != nil && !strings.HasPrefix(m[1], "[") { + out = append(out, m[1]) + } + } + return out +} + +// GetTOMLTable returns the scalar keys of one table. +func GetTOMLTable(path, name string) map[string]string { + raw, err := Read(path) + if err != nil || raw == nil { + return nil + } + lines := splitLines(string(raw)) + from, to := tableSpan(lines, name) + if from < 0 { + return nil + } + out := map[string]string{} + for _, line := range lines[from+1 : to] { + if m := tomlKV.FindStringSubmatch(line); m != nil { + out[strings.Trim(m[1], `"`)] = tomlValue(m[2]) + } + } + return out +} + +// SetTOMLTable replaces table `name` with the given keys, or appends it. +func SetTOMLTable(path, name string, kvs ...KV) error { + raw, err := Read(path) + if err != nil { + return err + } + block := []string{"[" + name + "]"} + for _, kv := range kvs { + block = append(block, kv.Path+" = "+tomlLiteral(kv.Value)) + } + lines := splitLines(string(raw)) + from, to := tableSpan(lines, name) + var out []string + if from < 0 { + out = trimBlank(lines) + if len(out) > 0 { + out = append(out, "") + } + out = append(out, block...) + out = append(out, "") + } else { + out = append(out, lines[:from]...) + out = append(out, block...) + if to < len(lines) && strings.TrimSpace(lines[to]) != "" { + out = append(out, "") + } + out = append(out, lines[to:]...) + } + return WriteAtomic(path, []byte(joinLines(out))) +} + +// DelTOMLTable removes table `name` (header and body) if present. +func DelTOMLTable(path, name string) error { + raw, err := Read(path) + if err != nil || raw == nil { + return err + } + lines := splitLines(string(raw)) + from, to := tableSpan(lines, name) + if from < 0 { + return nil + } + // Take the blank lines before the header with it so no gap is left. + for from > 0 && strings.TrimSpace(lines[from-1]) == "" { + from-- + } + out := append([]string{}, lines[:from]...) + if to < len(lines) && len(out) > 0 && strings.TrimSpace(lines[to]) != "" { + out = append(out, "") + } + out = append(out, lines[to:]...) + return WriteAtomic(path, []byte(joinLines(out))) +} + +// DelTOMLTop removes top-level (pre-table) keys. +func DelTOMLTop(path string, keys ...string) error { + raw, err := Read(path) + if err != nil || raw == nil { + return err + } + drop := map[string]bool{} + for _, k := range keys { + drop[k] = true + } + var out []string + header := true + for _, line := range splitLines(string(raw)) { + if header && tomlTable.MatchString(line) { + header = false + } + if header { + if m := tomlKV.FindStringSubmatch(line); m != nil && drop[strings.Trim(m[1], `"`)] { + continue + } + } + out = append(out, line) + } + return WriteAtomic(path, []byte(joinLines(out))) +} + +// tableSpan returns the line range [from, to) of a table: its header line +// through the line before the next header. from is -1 when absent. +func tableSpan(lines []string, name string) (int, int) { + from := -1 + for i, line := range lines { + m := tomlHeader.FindStringSubmatch(line) + if m == nil || strings.HasPrefix(m[1], "[") { + continue + } + if from >= 0 { + return from, i + } + if m[1] == name { + from = i + } + } + if from < 0 { + return -1, -1 + } + return from, len(lines) +} + +func trimBlank(lines []string) []string { + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func tomlLiteral(v any) string { + switch x := v.(type) { + case bool: + return strconv.FormatBool(x) + case int: + return strconv.Itoa(x) + } + return strconv.Quote(toString(v)) +} diff --git a/internal/edit/toml_test.go b/internal/edit/toml_test.go new file mode 100644 index 00000000..f445504b --- /dev/null +++ b/internal/edit/toml_test.go @@ -0,0 +1,86 @@ +package edit + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const tomlDoc = `model = "gpt-6-astra" # keep +approval_policy = "never" + +[notice] +hide = true + +[projects."/x"] +trust_level = "trusted" +` + +func tmpToml(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(p, []byte(tomlDoc), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestTOMLTables(t *testing.T) { + p := tmpToml(t) + got := strings.Join(TOMLTables(p), ",") + if got != `notice,projects."/x"` { + t.Fatalf("tables = %q", got) + } +} + +func TestSetAndDelTOMLTable(t *testing.T) { + p := tmpToml(t) + if err := SetTOMLTable(p, "model_providers.deepseek", KV{Path: "name", Value: "DeepSeek"}, KV{Path: "wire_api", Value: "responses"}); err != nil { + t.Fatal(err) + } + kv := GetTOMLTable(p, "model_providers.deepseek") + if kv["name"] != "DeepSeek" || kv["wire_api"] != "responses" { + t.Fatalf("table = %v", kv) + } + // replace in place, other tables intact + if err := SetTOMLTable(p, "model_providers.deepseek", KV{Path: "name", Value: "DS"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(p) + s := string(raw) + if strings.Count(s, "[model_providers.deepseek]") != 1 || strings.Contains(s, "wire_api") || !strings.Contains(s, "trust_level") || !strings.Contains(s, "# keep") { + t.Fatalf("after replace:\n%s", s) + } + if err := DelTOMLTable(p, "model_providers.deepseek"); err != nil { + t.Fatal(err) + } + raw, _ = os.ReadFile(p) + if string(raw) != tomlDoc { + t.Fatalf("delete did not restore:\n%s", raw) + } +} + +func TestSetTOMLTableMiddle(t *testing.T) { + p := tmpToml(t) + if err := SetTOMLTable(p, "notice", KV{Path: "hide", Value: false}, KV{Path: "n", Value: 3}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(p) + want := "[notice]\nhide = false\nn = 3\n\n[projects.\"/x\"]" + if !strings.Contains(string(raw), want) { + t.Fatalf("got:\n%s", raw) + } +} + +func TestDelTOMLTop(t *testing.T) { + p := tmpToml(t) + if err := DelTOMLTop(p, "model", "hide"); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(p) + s := string(raw) + if strings.Contains(s, "gpt-6-astra") || !strings.Contains(s, "hide = true") || !strings.HasPrefix(s, "approval_policy") { + t.Fatalf("got:\n%s", s) + } +} diff --git a/internal/gui/api.go b/internal/gui/api.go new file mode 100644 index 00000000..f5bb5b34 --- /dev/null +++ b/internal/gui/api.go @@ -0,0 +1,203 @@ +// Package gui hosts the desktop app: a tray panel and a regular window that +// share one small web UI. The UI talks to Go over a tiny JSON API served by +// the same handler that serves the static assets, so no binding generator or +// bundler is involved. +package gui + +import ( + "context" + "embed" + "encoding/json" + "io/fs" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/yetone/dial/internal/agent" + "github.com/yetone/dial/internal/catalog" + "github.com/yetone/dial/internal/profile" +) + +//go:embed assets +var assets embed.FS + +// Windows is what the API needs from the host application. +type Windows interface { + HidePanel() + ShowMain() + Quit() + // OpenURL hands a link to the system browser. + OpenURL(url string) + // FitPanel asks for the panel to be tall enough for its content. + FitPanel(height int) +} + +type fieldJSON struct { + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + Options []agent.Option `json:"options"` +} + +type agentJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Path string `json:"path"` + Fields []fieldJSON `json:"fields"` +} + +type profileJSON struct { + Name string `json:"name"` + Summary string `json:"summary"` +} + +type stateJSON struct { + Agents []agentJSON `json:"agents"` + Profiles []profileJSON `json:"profiles"` + Catalog string `json:"catalog"` + Notice string `json:"notice,omitempty"` // advice after a change, e.g. "restart Codex" +} + +// Handler serves the embedded UI and the JSON API. +func Handler(w Windows) http.Handler { + static, _ := fs.Sub(assets, "assets") + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.FS(static))) + mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) { + writeJSON(rw, state()) + }) + mux.HandleFunc("POST /api/set", func(rw http.ResponseWriter, r *http.Request) { + var in struct { + Agent, Field, Value string + Key struct{ Env, Value string } // API key to store first, if the option asked for one + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + if in.Key.Env != "" && in.Key.Value != "" { + if err := agent.SetKey(in.Key.Env, in.Key.Value); err != nil { + fail(rw, err) + return + } + } + a, err := agent.Find(in.Agent) + if err != nil { + fail(rw, err) + return + } + f := a.Field(in.Field) + if f == nil { + http.Error(rw, "unknown field", http.StatusBadRequest) + return + } + if err := f.Set(strings.TrimSpace(in.Value)); err != nil { + fail(rw, err) + return + } + s := state() + if a.Notice != nil { + s.Notice = a.Notice() + } + writeJSON(rw, s) + }) + mux.HandleFunc("POST /api/profile/{action}", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Name string } + _ = json.NewDecoder(r.Body).Decode(&in) + in.Name = strings.TrimSpace(in.Name) + var err error + changed := 0 + switch r.PathValue("action") { + case "save": + err = profile.Save(in.Name, profile.Snapshot()) + case "use": + var ps map[string]profile.Profile + if ps, err = profile.Load(); err == nil { + changed, err = profile.Apply(ps[in.Name]) + } + case "delete": + err = profile.Delete(in.Name) + default: + http.NotFound(rw, r) + return + } + if err != nil { + fail(rw, err) + return + } + s := state() + writeJSON(rw, struct { + stateJSON + Changed int `json:"changed"` + }{s, changed}) + }) + mux.HandleFunc("POST /api/sync", func(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if err := catalog.Sync(ctx); err != nil { + fail(rw, err) + return + } + writeJSON(rw, state()) + }) + providerRoutes(mux, w) + mux.HandleFunc("POST /api/window/{action}", func(rw http.ResponseWriter, r *http.Request) { + switch r.PathValue("action") { + case "hide": + w.HidePanel() + case "main": + w.ShowMain() + case "quit": + w.Quit() + case "fit": + if h, err := strconv.Atoi(r.URL.Query().Get("h")); err == nil { + w.FitPanel(h) + } + } + rw.WriteHeader(http.StatusNoContent) + }) + return mux +} + +func state() stateJSON { + s := stateJSON{Agents: []agentJSON{}, Profiles: []profileJSON{}, Catalog: catalog.Source()} + for _, a := range agent.Detected() { + vals := a.Values() + aj := agentJSON{ID: a.ID, Name: a.Name, Icon: a.Icon, Path: tilde(a.Path)} + for _, f := range a.Fields { + opts := f.Options(vals) + if opts == nil { + opts = []agent.Option{} + } + aj.Fields = append(aj.Fields, fieldJSON{Key: f.Key, Label: f.Label, Value: vals[f.Key], Options: opts}) + } + s.Agents = append(s.Agents, aj) + } + if ps, err := profile.Load(); err == nil { + for _, n := range profile.Names(ps) { + s.Profiles = append(s.Profiles, profileJSON{Name: n, Summary: profile.Summary(ps[n])}) + } + } + return s +} + +func writeJSON(rw http.ResponseWriter, v any) { + rw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(rw).Encode(v) +} + +func fail(rw http.ResponseWriter, err error) { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()}) +} + +func tilde(p string) string { + if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) { + return "~" + p[len(home):] + } + return p +} diff --git a/internal/gui/app.go b/internal/gui/app.go new file mode 100644 index 00000000..01f84294 --- /dev/null +++ b/internal/gui/app.go @@ -0,0 +1,131 @@ +package gui + +import ( + _ "embed" + "os" + "runtime" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +//go:embed tray.png +var trayIcon []byte // black glyph, tinted by the macOS menu bar + +//go:embed icon.png +var appIcon []byte // coloured, for other trays and the about box + +type host struct { + app *application.App + panel *application.WebviewWindow + main *application.WebviewWindow + tray *application.SystemTray + + panelHeight int +} + +func (h *host) HidePanel() { h.panel.Hide() } +func (h *host) ShowMain() { + h.panel.Hide() + h.main.Show() + h.main.Focus() +} +func (h *host) Quit() { h.app.Quit() } +func (h *host) OpenURL(url string) { _ = h.app.Browser.OpenURL(url) } + +const panelWidth, panelMin, panelMax = 440, 220, 720 + +// FitPanel grows or shrinks the panel to its content and keeps it anchored +// under the tray icon. +func (h *host) FitPanel(height int) { + height = max(panelMin, min(panelMax, height)) + if h.panelHeight == height { + return + } + h.panelHeight = height + h.panel.SetSize(panelWidth, height) + if h.panel.IsVisible() { + _ = h.tray.PositionWindow(h.panel, 6) + } +} + +// Run starts the desktop app: a menu bar icon whose click drops down a compact +// panel, plus a regular window for when you want it to stay around. +// showMain opens the window immediately; otherwise only the tray icon appears. +func Run(version string, showMain bool) error { + // DIAL_THEME=light|dark forces the palette; handy for screenshots. + theme := "" + if t := os.Getenv("DIAL_THEME"); t != "" { + theme = "&theme=" + t + } + h := &host{} + h.app = application.New(application.Options{ + Name: "dial", + Description: "one dial for every coding agent's model", + Icon: appIcon, + Assets: application.AssetOptions{Handler: Handler(h)}, + Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyAccessory}, + Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true}, + }) + + h.panel = h.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "panel", + Title: "dial", + URL: "/?mode=panel" + theme, + Width: panelWidth, + Height: 520, + Hidden: true, + Frameless: true, + AlwaysOnTop: true, + DisableResize: true, + HideOnEscape: true, + HideOnFocusLost: true, + BackgroundType: application.BackgroundTypeTranslucent, + Mac: application.MacWindow{ + Backdrop: application.MacBackdropTranslucent, + CornerRadius: 12, + }, + Windows: application.WindowsWindow{HiddenOnTaskbar: true}, + }) + + h.main = h.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "main", + Title: "dial", + URL: "/?" + theme, + Width: 660, + Height: 600, + MinWidth: 560, + MinHeight: 420, + Hidden: true, + Mac: application.MacWindow{ + TitleBar: application.MacTitleBarHiddenInset, + InvisibleTitleBarHeight: 44, + }, + }) + // Closing the window keeps the tray alive; quitting is a menu action. + h.main.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + h.main.Hide() + e.Cancel() + }) + + menu := h.app.NewMenu() + menu.Add("Open dial").OnClick(func(*application.Context) { h.ShowMain() }) + menu.AddSeparator() + menu.Add("Version " + version).SetEnabled(false) + menu.Add("Quit dial").OnClick(func(*application.Context) { h.app.Quit() }) + + h.tray = h.app.SystemTray.New() + h.tray.SetTooltip("dial") + if runtime.GOOS == "darwin" { + h.tray.SetTemplateIcon(trayIcon) + } else { + h.tray.SetIcon(appIcon) + } + h.tray.SetMenu(menu) + h.tray.AttachWindow(h.panel).WindowOffset(6) + + if showMain { + h.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { h.ShowMain() }) + } + return h.app.Run() +} diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css new file mode 100644 index 00000000..017104c2 --- /dev/null +++ b/internal/gui/assets/app.css @@ -0,0 +1,303 @@ +/* dial — one quiet surface, real logos, controls that read as pop-up buttons. */ +:root { + --bg: #f4f4f6; + --card: #ffffff; + --card-2: #f9f9fb; /* expanded editor */ + --pill: #f1f1f4; + --pill-hover: #e9e9ee; + --line: #e3e3e8; + --line-2: #ececf0; + --fg: #1c1c21; + --fg-2: #4b4b53; + --muted: #85858d; + --faint: #b9b9c2; + --accent: #4f46e5; + --accent-soft: #eeedfd; + --accent-fg: #ffffff; + --sel: #ecebfb; + --green: #189a58; + --green-soft: #e4f6ec; + --red: #d64545; + --red-soft: #fbe9e9; + --amber: #b45309; + --amber-soft: #fbf0df; + --pop-bg: rgba(255, 255, 255, .94); + --shadow: 0 16px 44px rgba(20, 22, 30, .16), 0 2px 8px rgba(20, 22, 30, .06), 0 0 0 .5px rgba(20, 22, 30, .1); + --mono-l: 40%; --mono-a: .13; + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif; + --code: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --logo-invert: 0; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #1a1a1e; + --card: #232327; + --card-2: #1f1f23; + --pill: #2d2d33; + --pill-hover: #36363d; + --line: #333339; + --line-2: #2c2c32; + --fg: #ededf1; + --fg-2: #b9b9c1; + --muted: #8b8b94; + --faint: #56565f; + --accent: #a5b4fc; + --accent-soft: #2b2c47; + --accent-fg: #14142a; + --sel: #33345a; + --green: #6fd99b; + --green-soft: #1e3a2b; + --red: #f28b82; + --red-soft: #45272a; + --amber: #f2b544; + --amber-soft: #3f3220; + --pop-bg: rgba(38, 38, 43, .96); + --shadow: 0 16px 44px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .3), 0 0 0 .5px rgba(255, 255, 255, .1); + --mono-l: 74%; --mono-a: .18; + --logo-invert: 1; + } +} +:root[data-theme="dark"] { + --bg: #1a1a1e; --card: #232327; --card-2: #1f1f23; --pill: #2d2d33; --pill-hover: #36363d; + --line: #333339; --line-2: #2c2c32; --fg: #ededf1; --fg-2: #b9b9c1; --muted: #8b8b94; --faint: #56565f; + --accent: #a5b4fc; --accent-soft: #2b2c47; --accent-fg: #14142a; --sel: #33345a; + --green: #6fd99b; --green-soft: #1e3a2b; --red: #f28b82; --red-soft: #45272a; --amber: #f2b544; --amber-soft: #3f3220; + --pop-bg: rgba(38, 38, 43, .96); + --shadow: 0 16px 44px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .3), 0 0 0 .5px rgba(255, 255, 255, .1); + --mono-l: 74%; --mono-a: .18; --logo-invert: 1; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font: 13px/1.4 var(--font); + color: var(--fg); + background: var(--bg); + display: flex; + flex-direction: column; + overflow: hidden; + -webkit-font-smoothing: antialiased; + user-select: none; + -webkit-user-select: none; + cursor: default; +} +/* The tray panel sits on a frosted NSVisualEffectView, so its own paint is thin. */ +body.panel { background: color-mix(in srgb, var(--bg) 80%, transparent); border-radius: 12px; } +button { font: inherit; color: inherit; background: none; border: 0; cursor: default; } +button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +input { font: inherit; color: var(--fg); } +.grow { flex: 1; } +[hidden] { display: none !important; } +code { font: 11.5px var(--code); } + +/* ---- brand icons: colour ones are images, mono ones take the text colour --- */ +.ic { display: inline-flex; flex: none; align-items: center; justify-content: center; } +.ic img { width: 100%; height: 100%; object-fit: contain; display: block; } +.ic .mask { width: 100%; height: 100%; background: currentColor; -webkit-mask: var(--i) center / contain no-repeat; mask: var(--i) center / contain no-repeat; } +.ic.monogram { + border-radius: 30%; + font-weight: 700; letter-spacing: .02em; + color: hsl(var(--h) 55% var(--mono-l)); + background: hsl(var(--h) 65% 55% / var(--mono-a)); +} + +/* ---- header --------------------------------------------------------------- */ +.top { display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; height: 46px; flex: none; position: relative; } +body.window .top { padding-left: 88px; } +.brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; } +.logo { color: var(--accent); display: inline-flex; } +.seg { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + display: inline-flex; padding: 2px; border-radius: 8px; + background: var(--pill); +} +body.panel .seg { display: none; } +.seg button { height: 24px; padding: 0 12px; border-radius: 6px; font-size: 12.5px; color: var(--muted); transition: background .15s, color .15s, box-shadow .15s; } +.seg button.on { background: var(--card); color: var(--fg); box-shadow: 0 1px 2px rgba(0, 0, 0, .12), 0 0 0 .5px rgba(0, 0, 0, .06); } +.actions { margin-left: auto; display: flex; gap: 2px; } +.icon { + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + color: var(--muted); + transition: background .12s, color .12s; +} +.icon:hover { background: var(--pill); color: var(--fg); } +.icon.spin svg { animation: spin .9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---- lists ---------------------------------------------------------------- */ +.view { flex: 1; min-height: 0; overflow-y: auto; padding: 0 12px 4px; display: flex; flex-direction: column; } +body.window .view { padding: 2px 16px 8px; } +.view::-webkit-scrollbar { width: 0; } +.list { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; flex: none; } +.row { display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 6px 10px 6px 12px; } +body.window .row { min-height: 48px; padding: 7px 12px 7px 14px; gap: 12px; } +.row + .row, .editor + .row, .row + .editor { border-top: 1px solid var(--line-2); } +.row .ic { width: 20px; height: 20px; font-size: 8.5px; } +body.window .row .ic { width: 22px; height: 22px; font-size: 9px; } +.row .name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.row .who { min-width: 0; flex: 1 1 auto; } +body.panel .agent .who { flex: 0 0 auto; max-width: 150px; } +.row .sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } +.empty-state { padding: 36px 20px; text-align: center; color: var(--muted); } +.empty-state b { display: block; color: var(--fg); font-weight: 500; margin-bottom: 4px; } + +/* an agent row: logo, name, then one pop-up button per setting */ +.agent .who { flex: 0 1 auto; max-width: 42%; } +.fields { display: flex; flex-wrap: wrap; gap: 4px 5px; justify-content: flex-end; min-width: 0; margin-left: auto; } +.field { + display: inline-flex; align-items: center; gap: 5px; + max-width: 100%; min-width: 0; + height: 25px; padding: 0 6px 0 8px; + border-radius: 7px; + background: var(--pill); + box-shadow: inset 0 0 0 1px var(--line-2); + font-size: 12.5px; + transition: background .12s, box-shadow .12s; +} +body.panel .field { height: 24px; font-size: 12px; } +.field:hover, .field.open { background: var(--pill-hover); box-shadow: inset 0 0 0 1px var(--line); } +.field.open { box-shadow: inset 0 0 0 1px var(--accent); } +.field .ic { width: 14px; height: 14px; font-size: 6.5px; } +.field .k { color: var(--muted); font-size: 11.5px; } +.field .v { min-width: 0; flex: 0 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } +.field .v.empty { color: var(--muted); } +.field .chev { color: var(--faint); display: inline-flex; margin-left: -1px; } +.field:hover .chev, .field.open .chev { color: var(--muted); } +.field.flash { animation: flash 1.2s ease-out; } +@keyframes flash { 0%, 35% { box-shadow: inset 0 0 0 1px var(--green); background: var(--green-soft); } 100% {} } + +/* ---- profiles ------------------------------------------------------------- */ +.profiles { padding: 8px 2px 0; flex: none; } +.row-head { display: flex; align-items: center; height: 26px; } +.label { font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); } +.text { font-size: 12px; color: var(--muted); padding: 3px 7px; border-radius: 6px; transition: background .12s, color .12s; white-space: nowrap; } +.text:hover { color: var(--fg); background: var(--pill); } +.text.primary { color: var(--accent-fg); background: var(--accent); font-weight: 500; } +.text.primary:hover { filter: brightness(1.08); } +.text.danger:hover { color: var(--red); background: var(--red-soft); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 0 6px; min-height: 12px; } +.chip { + display: inline-flex; align-items: center; gap: 2px; + height: 25px; padding: 0 5px 0 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card); + font-size: 12px; + transition: border-color .12s, background .12s, color .12s; +} +.chip:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.chip .x { width: 16px; height: 16px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; line-height: 1; opacity: 0; transition: opacity .12s; } +.chip:hover .x { opacity: .6; } +.chip .x:hover { opacity: 1; background: rgba(127, 127, 127, .2); } +.chip-input { + height: 25px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--accent); + background: var(--card); outline: none; width: 140px; font-size: 12px; +} +.chips .hint { font-size: 12px; color: var(--faint); line-height: 25px; } + +/* ---- footer --------------------------------------------------------------- */ +.foot { display: flex; align-items: center; gap: 2px; padding: 0 8px 6px 14px; min-height: 28px; flex: none; } +body.window .foot { padding-left: 18px; } +.status { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .3s; } +.status.ok { color: var(--green); } +.status.err { color: var(--red); } +.status.warn { color: var(--amber); } + +/* ---- picker popover ------------------------------------------------------- */ +.pop { + position: fixed; z-index: 10; + width: 320px; max-height: 340px; + display: flex; flex-direction: column; + background: var(--pop-bg); + -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); + border-radius: 11px; + box-shadow: var(--shadow); + overflow: hidden; + transform-origin: top left; + animation: pop .14s cubic-bezier(.2, .8, .3, 1); +} +.pop.up { transform-origin: bottom left; } +@keyframes pop { from { opacity: 0; transform: scale(.97) translateY(-3px); } } +.pop .search { display: flex; align-items: center; gap: 8px; padding: 0 12px; border-bottom: 1px solid var(--line-2); color: var(--faint); } +.pop input { flex: 1; background: none; border: 0; outline: none; height: 36px; font-size: 13px; } +.pop input::placeholder { color: var(--faint); } +.pop ul { list-style: none; overflow-y: auto; padding: 5px; } +.pop li { + display: flex; align-items: center; gap: 9px; + height: 30px; padding: 0 8px; border-radius: 7px; + white-space: nowrap; font-size: 12.5px; +} +.pop li .ic { width: 16px; height: 16px; font-size: 7px; } +.pop li .v { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } +.pop li .n { flex: 1 1 0; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; } +.pop li .n.nokey { color: var(--faint); } +.pop li.sel { background: var(--sel); } +.pop li .check { color: var(--accent); display: none; margin-left: auto; flex: none; } +.pop li.cur .check { display: inline-flex; } +.pop li.custom .v { color: var(--fg); } +.pop li.custom .n { color: var(--muted); } +.pop .none { padding: 10px 12px; color: var(--muted); font-size: 12px; white-space: normal; line-height: 1.45; } +.pop.keying .search { border-bottom-color: var(--accent); color: var(--accent); } +.pop .none b { color: var(--fg); font-weight: 500; } + +/* ---- providers page ------------------------------------------------------- */ +.provider .who { flex: 1 1 auto; } +.provider .name .badge { margin-left: 6px; } +.badge { font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--muted); vertical-align: 1px; } +.provider.selected { background: var(--card-2); } +.uses { display: flex; align-items: center; gap: 3px; margin-left: auto; } +.use { + width: 24px; height: 24px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + opacity: .32; filter: grayscale(1); + transition: opacity .12s, background .12s, filter .12s; +} +.use .ic { width: 16px; height: 16px; font-size: 7px; } +.use:hover { opacity: 1; filter: none; background: var(--pill); } +.use.on { opacity: 1; filter: none; background: var(--accent-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); } +.key { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); white-space: nowrap; min-width: 150px; justify-content: flex-end; } +.key.none { color: var(--amber); } +.key::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--amber); opacity: .7; } +.key.on { color: var(--fg-2); } +.key.on::before { background: var(--green); } +.provider .chev { color: var(--faint); display: inline-flex; transition: transform .15s; } +.provider.selected .chev { transform: rotate(90deg); } +.after-list { display: flex; align-items: center; gap: 8px; padding: 8px 4px 0; } +.hidden-line { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hidden-line button { color: var(--muted); text-decoration: underline; text-decoration-color: var(--faint); text-underline-offset: 2px; } +.hidden-line button:hover { color: var(--fg); } + +/* the inline editor under a provider row */ +.editor { background: var(--card-2); padding: 12px 14px 12px 48px; display: grid; grid-template-columns: 96px 1fr; gap: 8px 12px; align-items: center; } +body.panel .editor { padding-left: 14px; } +.editor label { font-size: 12px; color: var(--muted); text-align: right; } +.editor .hint { font-size: 11px; color: var(--faint); margin-top: -2px; } +.editor input[type="text"], .editor input[type="password"], .editor input[type="url"] { + height: 27px; padding: 0 9px; border-radius: 7px; + border: 1px solid var(--line); background: var(--card); + font-size: 12.5px; outline: none; width: 100%; min-width: 0; + transition: border-color .12s, box-shadow .12s; +} +.editor input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +.editor input:disabled { color: var(--muted); background: var(--pill); } +.editor input::placeholder { color: var(--faint); } +.editor .pair { display: flex; gap: 8px; align-items: center; } +.editor .pair input:first-child { flex: 1; } +.editor .pair .env { flex: 0 0 150px; font-family: var(--code); font-size: 11.5px; } +.editor .side { display: flex; gap: 2px; align-items: center; flex: none; } +.editor .side .text { padding: 3px 6px; } +.editor .links { display: flex; gap: 12px; font-size: 12px; } +.editor .links button { color: var(--accent); } +.editor .links button:hover { text-decoration: underline; text-underline-offset: 2px; } +.editor .results { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; min-height: 20px; font-size: 12px; } +.res { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; font-size: 11.5px; background: var(--pill); color: var(--muted); } +.res.ok { background: var(--green-soft); color: var(--green); } +.res.bad { background: var(--red-soft); color: var(--red); } +.res .ms { opacity: .75; font-variant-numeric: tabular-nums; } +.editor .bar { grid-column: 1 / -1; display: flex; align-items: center; gap: 4px; padding-top: 4px; border-top: 1px solid var(--line-2); margin-top: 4px; } +.editor .bar .grow { flex: 1; } +.editor .models { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--fg-2); } +.editor .models .muted { color: var(--muted); } +.editor .models .text { padding: 2px 6px; } +.busy { opacity: .55; pointer-events: none; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js new file mode 100644 index 00000000..32c9955c --- /dev/null +++ b/internal/gui/assets/app.js @@ -0,0 +1,631 @@ +// dial — one state object per view, rendered into a list. No framework. +const $ = (s) => document.querySelector(s); +const params = new URLSearchParams(location.search); +const mode = params.get("mode") || "window"; +document.body.classList.add(mode); +if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme"); + +let state = { agents: [], profiles: [], catalog: "" }; +let providers = null; // { providers, hidden, catalogs } +let view = "agents"; +let pick = null; // { agent, field, options, items, cursor, anchor, key?, keyFor?, onCommit? } +let editing = null; // provider id being edited, "" for a new one +let draft = null; // the editor's working copy + +async function api(path, body) { + const res = await fetch("/api/" + path, { + method: body === undefined ? "GET" : "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 204) return null; + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; +} + +function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; +} +function svg(d, size = 12, stroke = 1.6) { + const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + s.setAttribute("viewBox", "0 0 16 16"); + s.setAttribute("width", size); + s.setAttribute("height", size); + s.innerHTML = ``; + return s; +} +const CHEV = "m5.5 6.5 2.5 2.5 2.5-2.5"; +const CHEV_R = "m6.5 4.5 3 3.5-3 3.5"; +const CHECK = "m3.5 8.5 3 3 6-7"; + +// A brand icon: colour logos are images, mono logos take the text colour, +// anything unknown gets a two-letter monogram tinted by its name. +function icon(name, label) { + const e = el("span", "ic"); + if (name) { + if (name.endsWith("-color") || name === "crush") { + const img = el("img"); + img.src = `icons/${name}.${name === "crush" ? "png" : "svg"}`; + img.alt = ""; + img.draggable = false; + e.append(img); + } else { + const m = el("span", "mask"); + m.style.setProperty("--i", `url(icons/${name}.svg)`); + e.append(m); + } + return e; + } + const w = (label || "?").split(/\s+/); + e.classList.add("monogram"); + e.textContent = (w.length > 1 ? w[0][0] + w[1][0] : (label || "?").slice(0, 2)).toUpperCase(); + let h = 0; + for (const ch of label || "") h = (h * 31 + ch.charCodeAt(0)) % 360; + e.style.setProperty("--h", h); + return e; +} + +function status(msg, kind = "", ms = 3500) { + const s = $("#status"); + s.textContent = msg; + s.title = msg; + s.className = "status " + kind; + clearTimeout(status.t); + if (msg) status.t = setTimeout(() => { s.textContent = ""; s.className = "status"; }, ms); +} + +// ---------- agents view ---------- + +function optionFor(field, value) { + return field.options.find((o) => o.value === value); +} + +function renderAgents() { + const list = $("#agents"); + list.replaceChildren(); + if (!state.agents.length) { + const e = el("div", "empty-state"); + e.append(el("b", "", "No coding agents found"), el("span", "", "Install Claude Code, Codex, Gemini CLI, OpenCode… and dial will list them here.")); + list.append(e); + } + for (const a of state.agents) { + const row = el("div", "row agent"); + row.dataset.id = a.id; + row.title = a.path; + const who = el("div", "who"); + who.append(el("div", "name", a.name)); + const fields = el("div", "fields"); + for (const f of a.fields) { + const b = el("button", "field"); + const opt = optionFor(f, f.value); + b.title = `${f.label}: ${f.value || "agent default"}`; + if (opt?.icon || (f.key === "provider" && f.value)) b.append(icon(opt?.icon, opt?.label || f.value)); + else if (f.label !== "model" || !f.value) b.append(el("span", "k", f.label)); + b.append(el("span", "v" + (f.value ? "" : " empty"), opt?.label || f.value || "default")); + const c = el("span", "chev"); + c.append(svg(CHEV, 11, 1.7)); + b.append(c); + b.onclick = (ev) => openPicker(a, f, b, ev); + fields.append(b); + } + row.append(icon(a.icon, a.name), who, fields); + list.append(row); + } + + const chips = $("#profiles"); + chips.replaceChildren(); + if (!state.profiles.length) chips.append(el("span", "hint", "Save the current setup to switch everything back in one click.")); + for (const p of state.profiles) { + const c = el("button", "chip"); + c.title = p.summary; + c.append(el("span", "", p.name)); + const x = el("span", "x", "×"); + x.title = "Delete profile"; + x.onclick = (ev) => { ev.stopPropagation(); profileAction("delete", p.name); }; + c.append(x); + c.onclick = () => profileAction("use", p.name); + chips.append(c); + } + fit(); +} + +// The tray panel has no scrollbars to speak of, so it grows to fit instead. +function fit() { + if (mode !== "panel") return; + const h = $(".top").offsetHeight + $("#agents").offsetHeight + $(".profiles").offsetHeight + $(".foot").offsetHeight + 4; + if (h !== fit.last) { fit.last = h; api("window/fit?h=" + h, {}); } +} + +async function load() { + try { + state = await api("state"); + renderAgents(); + if (view === "providers") await loadProviders(); + } catch (e) { + status(e.message, "err"); + } +} + +// ---------- picker ---------- + +function score(q, o) { + if (!q) return 1; + const lv = o.value.toLowerCase(), ll = (o.label || "").toLowerCase(); + if (lv === q || ll === q) return 100; + if (lv.startsWith(q) || ll.startsWith(q)) return 60; + if (lv.includes(q) || ll.includes(q)) return 40; + let i = 0; + for (const ch of lv) if (ch === q[i]) i++; + if (i === q.length) return 20; + if (o.note && o.note.toLowerCase().includes(q)) return 10; + return 0; +} + +function placePop(anchor, w, h) { + const pop = $("#pop"); + const r = anchor.getBoundingClientRect(), pad = 8; + pop.style.width = w + "px"; + let x = Math.min(r.left, innerWidth - w - pad); + let y = r.bottom + 5; + pop.classList.remove("up"); + if (y + h > innerHeight - pad && r.top - 5 - h >= pad) { y = r.top - 5 - h; pop.classList.add("up"); } + else if (y + h > innerHeight - pad) y = Math.max(pad, innerHeight - pad - h); + pop.style.left = Math.max(pad, x) + "px"; + pop.style.top = y + "px"; +} + +function openPicker(agent, field, anchor, ev) { + ev.stopPropagation(); + closePicker(); + const cur = field.value; + // current value first, then the rest in catalog order + const options = [...field.options]; + const i = options.findIndex((o) => o.value === cur); + if (i > 0) options.unshift(...options.splice(i, 1)); + else if (i < 0 && cur) options.unshift({ value: cur, note: "current value" }); + pick = { agent, field, options, anchor, cursor: 0 }; + anchor.classList.add("open"); + const pop = $("#pop"); + pop.hidden = false; + placePop(anchor, options.some((o) => o.note && o.note !== o.value) ? 360 : 300, 340); + const q = $("#q"); + q.value = ""; + q.placeholder = field.label === "model" ? "Filter, or type any model id…" : `Filter ${field.label}…`; + filter(); + q.focus(); +} + +function filter() { + if (!pick) return; + const q = $("#q").value.trim().toLowerCase(); + const scored = pick.options.map((o) => ({ o, s: score(q, o) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s); + pick.items = scored.map((x) => x.o); + const typed = $("#q").value.trim(); + if (typed && pick.field.label !== "provider" && pick.field.label !== "auth" && !pick.items.some((o) => o.value === typed)) { + pick.items.push({ value: typed, note: "use as typed", custom: true }); + } + pick.cursor = 0; + renderList(); +} + +function renderList() { + const list = $("#list"); + list.replaceChildren(); + if (!pick.items.length) { list.append(el("div", "none", "No matches.")); return; } + const hasIcons = pick.items.some((o) => o.icon); + pick.items.forEach((o, idx) => { + const li = el("li", (idx === pick.cursor ? "sel" : "") + (o.value === pick.field.value ? " cur" : "") + (o.custom ? " custom" : "")); + if (hasIcons) li.append(icon(o.icon, o.label || o.value)); + li.append(el("span", "v", o.label || o.value)); + const note = o.note && o.note !== (o.label || o.value) ? o.note : ""; + if (note) li.append(el("span", "n" + (o.needKey ? " nokey" : ""), note)); + const ck = el("span", "check"); + ck.append(svg(CHECK, 12, 1.8)); + li.append(ck); + li.onmousemove = () => { if (pick.cursor !== idx) { pick.cursor = idx; renderList(); } }; + li.onclick = () => commit(o.value); + list.append(li); + }); + list.children[pick.cursor]?.scrollIntoView({ block: "nearest" }); +} + +function move(d) { + if (!pick || !pick.items.length) return; + pick.cursor = (pick.cursor + d + pick.items.length) % pick.items.length; + renderList(); +} + +async function commit(value) { + if (!pick || !value) return; + const { agent, field } = pick; + const opt = pick.options.find((o) => o.value === value); + if (opt?.needKey && !pick.key) return askKey(opt); + const key = pick.key; + closePicker(); + if (value === field.value && !key) return; + try { + state = await api("set", { agent: agent.id, field: field.key, value, key }); + renderAgents(); + const b = document.querySelector(`.agent[data-id="${agent.id}"] .field:nth-child(${agent.fields.indexOf(field) + 1})`); + b?.classList.add("flash"); + const shown = opt?.label || value; + if (state.notice) status(`${agent.name} → ${shown}. ${state.notice}`, "warn", 9000); + else status(`${agent.name} ${field.label} → ${shown}`, "ok"); + if (providers) loadProviders(); + } catch (e) { + status(e.message, "err"); + } +} + +// A provider whose key is nowhere yet: the popover turns into a key prompt. +// The key goes to dial's store, then the switch proceeds as normal. +function askKey(opt) { + const pop = $("#pop"); + pop.classList.add("keying"); + const q = $("#q"); + q.type = "password"; + q.value = ""; + q.placeholder = `Paste your ${opt.key}, then ↩`; + pick.items = []; + pick.keyFor = opt; + const list = $("#list"); + list.replaceChildren(); + const li = el("div", "none"); + const b = el("b", "", opt.label || opt.value); + li.append(b, ` needs an API key. dial keeps it in ~/.config/dial/keys (mode 0600) and writes it into the agent's config when you switch.`); + list.append(li); + q.focus(); +} + +function closePicker() { + if (!pick) return; + pick.anchor.classList.remove("open"); + const pop = $("#pop"); + pop.hidden = true; + pop.classList.remove("keying"); + $("#q").type = "text"; + pick = null; +} + +$("#q").addEventListener("input", () => { if (!pick?.keyFor) filter(); }); +$("#q").addEventListener("keydown", (e) => { + if (pick?.keyFor) { + if (e.key === "Enter") { + e.preventDefault(); + const v = $("#q").value.trim(); + if (!v) return; + pick.key = { env: pick.keyFor.key, value: v }; + commit(pick.keyFor.value); + } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closePicker(); } + return; + } + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault(); move(1); } + else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { e.preventDefault(); move(-1); } + else if (e.key === "Enter") { e.preventDefault(); commit(pick?.items[pick.cursor]?.value); } + else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closePicker(); } +}); +document.addEventListener("mousedown", (e) => { if (pick && !$("#pop").contains(e.target)) closePicker(); }); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !pick && mode === "panel") api("window/hide", {}); +}); + +// ---------- profiles ---------- + +async function profileAction(action, name) { + try { + const data = await api("profile/" + action, { name }); + state = data; + renderAgents(); + if (action === "use") status(`${name} applied · ${data.changed} setting${data.changed === 1 ? "" : "s"} changed`, "ok"); + else if (action === "save") status(`Saved ${name}`, "ok"); + else status(`Deleted ${name}`); + } catch (e) { + status(e.message, "err"); + } +} + +$("#save").onclick = () => { + const chips = $("#profiles"); + if (chips.querySelector(".chip-input")) return; + const input = el("input", "chip-input"); + input.placeholder = "Profile name"; + input.onkeydown = (e) => { + if (e.key === "Enter" && input.value.trim()) profileAction("save", input.value.trim()); + else if (e.key === "Escape") input.remove(); + e.stopPropagation(); + }; + input.onblur = () => setTimeout(() => input.remove(), 100); + chips.prepend(input); + input.focus(); +}; + +// ---------- providers view ---------- + +async function loadProviders() { + providers = await api("providers"); + renderProviders(); +} + +function keyLabel(p) { + if (p.key.state === "env") return `$${p.envKey}`; + if (p.key.state === "stored") return "key saved"; + return "no key"; +} + +function renderProviders() { + const list = $("#providers"); + list.replaceChildren(); + for (const p of providers.providers) { + const row = el("div", "row provider" + (editing === p.id ? " selected" : "")); + row.dataset.id = p.id; + const who = el("div", "who"); + const name = el("div", "name", p.name); + if (!p.builtin) name.append(el("span", "badge", "custom")); + who.append(name, el("div", "sub", p.host + (p.models.count ? ` · ${p.models.count} models` : ""))); + const uses = el("div", "uses"); + for (const a of p.agents) { + const b = el("button", "use" + (a.current ? " on" : "")); + b.title = a.current ? `${a.name} uses ${p.name}` : `Switch ${a.name} to ${p.name}`; + b.append(icon(a.icon, a.name)); + b.onclick = (ev) => { ev.stopPropagation(); switchAgent(a, p, b); }; + uses.append(b); + } + const key = el("span", "key " + (p.key.state ? "on" : "none"), keyLabel(p)); + key.title = p.key.state ? `${p.envKey} ${p.key.masked}` : `${p.envKey} is not set`; + const chev = el("span", "chev"); + chev.append(svg(CHEV_R, 11, 1.7)); + row.append(icon(p.icon, p.name), who, uses, key, chev); + row.onclick = () => { editing = editing === p.id ? null : p.id; draft = null; renderProviders(); }; + list.append(row); + if (editing === p.id) list.append(renderEditor(p)); + } + if (editing === "") list.append(renderEditor()); + list.querySelector(".editor")?.scrollIntoView({ block: "nearest" }); + const hl = $("#hiddenLine"); + hl.replaceChildren(); + if (providers.hidden.length) { + hl.append("Hidden: "); + providers.hidden.forEach((h, i) => { + const b = el("button", "", h.name); + b.title = `Show ${h.name} again`; + b.onclick = () => providerAction("reset", { id: h.id }, `${h.name} is back`); + if (i) hl.append(", "); + hl.append(b); + }); + } +} + +// Switch an agent to a provider straight from the providers page. A missing +// key opens the editor with the key field ready. +async function switchAgent(a, p, btn) { + if (a.current) return; + if (!p.key.state) { + editing = p.id; draft = null; + renderProviders(); + status(`${p.name} needs $${p.envKey} first`, "warn"); + document.querySelector(".editor input[type=password]")?.focus(); + return; + } + btn.classList.add("busy"); + try { + state = await api("set", { agent: a.id, field: "provider", value: p.id }); + renderAgents(); + await loadProviders(); + if (state.notice) status(`${a.name} → ${p.name}. ${state.notice}`, "warn", 9000); + else status(`${a.name} → ${p.name}`, "ok"); + } catch (e) { + btn.classList.remove("busy"); + status(e.message, "err"); + } +} + +async function providerAction(action, body, okMsg) { + try { + providers = await api("provider/" + action, body); + if (action !== "save" || editing === "") editing = null; + draft = null; + renderProviders(); + state = await api("state"); + renderAgents(); + if (okMsg) status(okMsg, "ok"); + } catch (e) { + status(e.message, "err"); + } +} + +function field(label, control, hint) { + const l = el("label", "", label); + const wrap = el("div"); + wrap.append(control); + if (hint) wrap.append(el("div", "hint", hint)); + return [l, wrap]; +} +function input(value, placeholder, type = "text") { + const i = el("input"); + i.type = type; + i.value = value || ""; + i.placeholder = placeholder || ""; + i.spellcheck = false; + i.autocomplete = "off"; + i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") { editing = null; draft = null; renderProviders(); } }; + return i; +} + +function renderEditor(p) { + const isNew = !p; + draft = draft || (p ? { ...p, extra: [...p.extra] } : { id: "", name: "", envKey: "", anthropic: "", responses: "", catalog: "", website: "", keysUrl: "", builtin: false, extra: [] }); + const ed = el("div", "editor"); + ed.onclick = (e) => e.stopPropagation(); + + const name = input(draft.name, "e.g. My Gateway"); + name.oninput = () => { draft.name = name.value; if (isNew) { draft.id = slug(name.value); id.value = draft.id; } }; + const id = input(draft.id, "auto"); + id.disabled = !isNew; + id.oninput = () => { draft.id = id.value; }; + const idWrap = el("div", "pair"); + idWrap.append(name, id); + id.style.flex = "0 0 130px"; + id.title = "Identifier used in agent configs"; + ed.append(...field("Name", idWrap, isNew ? "The id on the right is what agent configs will reference." : "")); + + const anth = input(draft.anthropic, "https://…/anthropic", "url"); + anth.oninput = () => { draft.anthropic = anth.value; }; + ed.append(...field("Anthropic API", anth, "Base URL of a Messages-compatible endpoint. Used by Claude Code.")); + const resp = input(draft.responses, "https://…/v1", "url"); + resp.oninput = () => { draft.responses = resp.value; }; + ed.append(...field("Responses API", resp, "Base URL of an OpenAI Responses-compatible endpoint. Used by Codex.")); + + const env = input(draft.envKey, "MY_API_KEY"); + env.className = "env"; + env.oninput = () => { draft.envKey = env.value; }; + const key = input("", p?.key?.state === "env" ? `set in your shell · ${p.key.masked}` : p?.key?.state === "stored" ? `saved · ${p.key.masked}` : "paste an API key", "password"); + key.oninput = () => { draft.key = key.value; }; + const side = el("div", "side"); + const eye = el("button", "text", "Show"); + eye.onclick = () => { key.type = key.type === "password" ? "text" : "password"; eye.textContent = key.type === "password" ? "Show" : "Hide"; }; + side.append(eye); + if (p?.key?.state === "stored") { + const forget = el("button", "text danger", "Forget"); + forget.onclick = async () => { + try { providers = await api("key", { env: p.envKey, value: "" }); status(`Forgot $${p.envKey}`); renderProviders(); } catch (e) { status(e.message, "err"); } + }; + side.append(forget); + } + const keyWrap = el("div", "pair"); + keyWrap.append(key, env, side); + ed.append(...field("API key", keyWrap, "Kept in ~/.config/dial/keys (0600). A variable already in your shell wins.")); + + const cat = input(draft.catalog, "models.dev provider id, e.g. deepseek"); + cat.setAttribute("list", "catalogs"); + cat.oninput = () => { draft.catalog = cat.value; }; + const dl = el("datalist"); + dl.id = "catalogs"; + for (const c of providers.catalogs) dl.append(new Option(c)); + const catWrap = el("div"); + catWrap.append(cat, dl); + ed.append(...field("Catalog", catWrap, "Model names and reasoning levels, used until the vendor's own list is fetched.")); + + const extra = input(draft.extra.join(", "), "extra model ids, comma separated"); + extra.oninput = () => { draft.extra = extra.value.split(/[,\s]+/).filter(Boolean); }; + ed.append(...field("Own models", extra)); + + if (p) { + const m = el("div", "models"); + if (p.models.live) m.append(el("span", "", `${p.models.count} models from ${p.host}`), el("span", "muted", `· ${p.models.fetched}`)); + else m.append(el("span", "", `${p.models.count} models from the catalog`)); + const refresh = el("button", "text", "Refresh"); + refresh.title = "Ask the vendor which models it serves"; + refresh.onclick = async () => { + refresh.classList.add("busy"); + try { + const r = await api("provider/models", { id: p.id }); + status(`${p.name}: ${r.models.length} models`, "ok"); + await loadProviders(); + } catch (e) { status(e.message, "err"); refresh.classList.remove("busy"); } + }; + m.append(refresh); + ed.append(...field("Models", m)); + } + + const links = el("div", "links"); + const site = draft.website || (draft.anthropic || draft.responses ? "https://" + hostOf(draft.anthropic || draft.responses) : ""); + if (site) { const b = el("button", "", "Website ↗"); b.onclick = () => api("open", { url: site }); links.append(b); } + if (draft.keysUrl) { const b = el("button", "", "Get an API key ↗"); b.onclick = () => api("open", { url: draft.keysUrl }); links.append(b); } + if (links.children.length) ed.append(...field("", links)); + + const bar = el("div", "bar"); + const results = el("div", "results"); + if (p) { + const test = el("button", "text", "Test connection"); + test.onclick = async () => { + test.classList.add("busy"); + results.replaceChildren(el("span", "res", "testing…")); + try { + const r = await api("provider/test", { id: p.id }); + results.replaceChildren(); + for (const t of r.results) { + const b = el("span", "res " + (t.ok ? "ok" : "bad")); + b.append(el("span", "", `${t.protocol} ${t.ok ? "✓" : "✗"}`)); + if (t.ok) b.append(el("span", "ms", `${t.ms} ms`)); + else b.append(el("span", "", t.status ? `${t.status} · ${t.error}` : t.error)); + b.title = t.model ? `model ${t.model}` : ""; + results.append(b); + } + } catch (e) { results.replaceChildren(); status(e.message, "err"); } + test.classList.remove("busy"); + }; + bar.append(test, results); + } + bar.append(el("span", "grow")); + if (p && p.builtin) { + const reset = el("button", "text", "Reset"); + reset.title = "Back to dial's defaults"; + reset.onclick = () => providerAction("reset", { id: p.id }, `${p.name} reset`); + const hide = el("button", "text danger", "Hide"); + hide.title = "Hide this built-in provider"; + hide.onclick = () => providerAction("delete", { id: p.id }, `${p.name} hidden`); + bar.append(reset, hide); + } else if (p) { + const del = el("button", "text danger", "Delete"); + del.onclick = () => providerAction("delete", { id: p.id }, `${p.name} deleted`); + bar.append(del); + } + const cancel = el("button", "text", "Cancel"); + cancel.onclick = () => { editing = null; draft = null; renderProviders(); }; + const save = el("button", "text primary", isNew ? "Add provider" : "Save"); + save.onclick = () => { + const body = { id: draft.id, name: draft.name, envKey: draft.envKey, catalog: draft.catalog, anthropic: draft.anthropic, responses: draft.responses, small: draft.small, models: draft.extra, website: draft.website, keysUrl: draft.keysUrl, icon: draft.icon, key: draft.key || "" }; + providerAction("save", body, `${draft.name || draft.id} saved`); + }; + bar.append(cancel, save); + ed.append(bar); + setTimeout(() => (isNew ? name : null)?.focus(), 0); + return ed; +} + +function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); } +function hostOf(u) { try { return new URL(u.includes("://") ? u : "https://" + u).host; } catch { return ""; } } + +$("#addProvider").onclick = () => { editing = ""; draft = null; renderProviders(); }; + +// ---------- header / footer ---------- + +function show(v) { + view = v; + for (const b of $("#nav").children) b.classList.toggle("on", b.dataset.view === v); + $("#view-agents").hidden = v !== "agents"; + $("#view-providers").hidden = v !== "providers"; + closePicker(); + if (v === "providers") loadProviders().catch((e) => status(e.message, "err")); +} +for (const b of $("#nav").children) b.onclick = () => { show(b.dataset.view); b.blur(); }; + +$("#sync").onclick = async () => { + const b = $("#sync"); + b.classList.add("spin"); + try { + state = await api("sync", {}); + renderAgents(); + if (providers) await loadProviders(); + status("Model catalog refreshed", "ok"); + } catch (e) { + status("Sync failed: " + e.message, "err"); + } finally { + b.classList.remove("spin"); + } +}; +$("#open").onclick = () => api("window/main", {}); +$("#openMain").onclick = () => api("window/main", {}); +$("#quit").onclick = () => api("window/quit", {}); +if (mode === "window") { $("#open").remove(); $("#openMain").remove(); $("#quit").remove(); } +else { $("#nav").remove(); } + +// Config files may change underneath us (another dial, an editor); reload when +// the panel comes back into view. +document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); }); +window.addEventListener("focus", load); +load(); diff --git a/internal/gui/assets/icons/claude-color.svg b/internal/gui/assets/icons/claude-color.svg new file mode 100644 index 00000000..62dc0db1 --- /dev/null +++ b/internal/gui/assets/icons/claude-color.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/internal/gui/assets/icons/claudecode-color.svg b/internal/gui/assets/icons/claudecode-color.svg new file mode 100644 index 00000000..ed1293c4 --- /dev/null +++ b/internal/gui/assets/icons/claudecode-color.svg @@ -0,0 +1 @@ +Claude Code \ No newline at end of file diff --git a/internal/gui/assets/icons/codex-color.svg b/internal/gui/assets/icons/codex-color.svg new file mode 100644 index 00000000..c77ccfdd --- /dev/null +++ b/internal/gui/assets/icons/codex-color.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/internal/gui/assets/icons/crush.png b/internal/gui/assets/icons/crush.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7d339096999779391349389f3879b6f2e15fc0 GIT binary patch literal 16519 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 \ No newline at end of file diff --git a/internal/gui/assets/icons/deepseek-color.svg b/internal/gui/assets/icons/deepseek-color.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/internal/gui/assets/icons/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/internal/gui/assets/icons/gemini-color.svg b/internal/gui/assets/icons/gemini-color.svg new file mode 100644 index 00000000..62681dfa --- /dev/null +++ b/internal/gui/assets/icons/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/internal/gui/assets/icons/geminicli-color.svg b/internal/gui/assets/icons/geminicli-color.svg new file mode 100644 index 00000000..e15f1f7e --- /dev/null +++ b/internal/gui/assets/icons/geminicli-color.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/internal/gui/assets/icons/githubcopilot.svg b/internal/gui/assets/icons/githubcopilot.svg new file mode 100644 index 00000000..3cbf22a5 --- /dev/null +++ b/internal/gui/assets/icons/githubcopilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/internal/gui/assets/icons/googlecloud-color.svg b/internal/gui/assets/icons/googlecloud-color.svg new file mode 100644 index 00000000..80def4a1 --- /dev/null +++ b/internal/gui/assets/icons/googlecloud-color.svg @@ -0,0 +1 @@ +GoogleCloud \ No newline at end of file diff --git a/internal/gui/assets/icons/goose.svg b/internal/gui/assets/icons/goose.svg new file mode 100644 index 00000000..10820907 --- /dev/null +++ b/internal/gui/assets/icons/goose.svg @@ -0,0 +1 @@ +Goose \ No newline at end of file diff --git a/internal/gui/assets/icons/kimi.svg b/internal/gui/assets/icons/kimi.svg new file mode 100644 index 00000000..1915850e --- /dev/null +++ b/internal/gui/assets/icons/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/internal/gui/assets/icons/minimax-color.svg b/internal/gui/assets/icons/minimax-color.svg new file mode 100644 index 00000000..beb7adb8 --- /dev/null +++ b/internal/gui/assets/icons/minimax-color.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/internal/gui/assets/icons/openai.svg b/internal/gui/assets/icons/openai.svg new file mode 100644 index 00000000..78caf4fa --- /dev/null +++ b/internal/gui/assets/icons/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/internal/gui/assets/icons/opencode.svg b/internal/gui/assets/icons/opencode.svg new file mode 100644 index 00000000..9c4cb89e --- /dev/null +++ b/internal/gui/assets/icons/opencode.svg @@ -0,0 +1 @@ +opencode \ No newline at end of file diff --git a/internal/gui/assets/icons/openrouter.svg b/internal/gui/assets/icons/openrouter.svg new file mode 100644 index 00000000..029070c8 --- /dev/null +++ b/internal/gui/assets/icons/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/internal/gui/assets/icons/pi.svg b/internal/gui/assets/icons/pi.svg new file mode 100644 index 00000000..5122fb5e --- /dev/null +++ b/internal/gui/assets/icons/pi.svg @@ -0,0 +1 @@ +Pi \ No newline at end of file diff --git a/internal/gui/assets/icons/qwen-color.svg b/internal/gui/assets/icons/qwen-color.svg new file mode 100644 index 00000000..f2d0ada1 --- /dev/null +++ b/internal/gui/assets/icons/qwen-color.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/internal/gui/assets/icons/zai.svg b/internal/gui/assets/icons/zai.svg new file mode 100644 index 00000000..04ba2d98 --- /dev/null +++ b/internal/gui/assets/icons/zai.svg @@ -0,0 +1 @@ +Z.ai \ No newline at end of file diff --git a/internal/gui/assets/icons/zhipu-color.svg b/internal/gui/assets/icons/zhipu-color.svg new file mode 100644 index 00000000..0c6e61ce --- /dev/null +++ b/internal/gui/assets/icons/zhipu-color.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html new file mode 100644 index 00000000..c8f9588f --- /dev/null +++ b/internal/gui/assets/index.html @@ -0,0 +1,67 @@ + + + + + +dial + + + +
+
+ + dial +
+ +
+ + +
+
+ +
+
+
+
+ Profiles + + +
+
+
+
+ +
+
+
+ + + +
+
+ +
+ + + + +
+ + + + + + diff --git a/internal/gui/icon.png b/internal/gui/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0374b4e602c23066eba2d80b411f807a228f7460 GIT binary patch literal 1670 zcmV;126_33P)k&?ORT=+&mbEhHt>Kcw4UF&s7~u&pngOG8fDPLKfDnKh0M~NVWCq6pv;$}X z&~1glN(R&c*Z`p3<^b|U62LwHjR0EB7&=Y#^#W)TN4uebf`_Q3LDbeHYBgwRECVKp z=um&+Q}0noSwxRtjqrvtUKOEG&+8K&RrL0MVu z`m4V`jkaGCIC!8RhYwyrD$Q-5q8f`kpPNCa3z#>*97`XnfY0aB?N0-9du^%EdLK{W z^Uu!X`|sjNr}C%KNFihil;Z22@ndSxW67QdY}Hehn0r?_UVFJmxJB{-qouGZ1gDL` z#?1kE$0}3(ba()*EpZ%eOCWq=0I^tFIPUd2F?mV}Zkg^uU8o#aPcF&W&;Y*jdNp3! z+KtYR|F~;)+<$ji?Oc6u5BqTB@C8F0bPGJXY#b)Z27=~? zZ7+1;SVzLFB#6b(@?0mJLI!YZl=d4pD}&#+CyMoJBRF!X-!KpLB^$DpK%B%)K6q+& z(j36!k0!iz&-xW<|8dhfy!A#85{a~FKC&TO;*dbxW_n79XU*N^|_pZdN+b0ThiFBN} zh*JXjatf;|2kks=rCGDf;Pbm=so#r4f|=sDXHf-KKOGPzzU!{{VC9qk%sJvBP6_0T zeDcuFB?IQ%=~Z0)9}mS%+24C#1y-!`D`w{sk3-RiIOSO|=#xtZ(3P^+X53bq5!U)c z)UaJ3;KHKCR}2ZB@YT+~A^nM40{P^j-S%#eiWd2@*Y;b#u1%%Sg-u%~;D)JVa-M(x z-EQpNeSXOCL2jS$Klvozr)61T1-4&|R97p<@Wk;1^f>i`|9V$n&U3__?fcY8ONCJe zW*&>@gwLm>h(*(y{R34l)!;wap&ooL?rh&yVEf(RP;2Xa2U?=0?3qn};BgD|_9!1> zlzW|sMb&q1UH=T=kKZrg#;K)><8+M9X~8?zHN>}F{kC)HY(yz~%9Ijao2{Gv5(1V$ z|I;x&WOHZxwgTH35I&g@c52c}P&1*SP4iq_s1$Lf`T;#fx8YgelgEWH