initial commit
- commit
- 6cced2747608cf29d7416c9842b61779fb2df0d6
- author
- patriceckhart · · 2026-04-17 18:36 UTC
- parent
- none (root commit)
- signature
- unsigned
64 files · +11631 -0
A LICENSE +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | MIT License | |
| 2 | + | ||
| 3 | + | Copyright (c) 2026 Patric Eckhart | |
| 4 | + | ||
| 5 | + | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |
| 6 | + | ||
| 7 | + | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | |
| 8 | + | ||
| 9 | + | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
A Makefile +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | VERSION ?= dev | |
| 2 | + | LDFLAGS := -s -w -X main.version=$(VERSION) | |
| 3 | + | ||
| 4 | + | .PHONY: build install test lint fmt clean release | |
| 5 | + | ||
| 6 | + | build: | |
| 7 | + | go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot ./cmd/zot | |
| 8 | + | ||
| 9 | + | install: | |
| 10 | + | go install -ldflags "$(LDFLAGS)" ./cmd/zot | |
| 11 | + | ||
| 12 | + | test: | |
| 13 | + | go test -race ./... | |
| 14 | + | ||
| 15 | + | lint: | |
| 16 | + | go vet ./... | |
| 17 | + | @test -z "$$(gofmt -l . | tee /dev/stderr)" || (echo "gofmt issues"; exit 1) | |
| 18 | + | ||
| 19 | + | fmt: | |
| 20 | + | gofmt -w . | |
| 21 | + | ||
| 22 | + | clean: | |
| 23 | + | rm -rf bin | |
| 24 | + | ||
| 25 | + | release: | |
| 26 | + | @mkdir -p bin | |
| 27 | + | GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-amd64 ./cmd/zot | |
| 28 | + | GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-linux-arm64 ./cmd/zot | |
| 29 | + | GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-amd64 ./cmd/zot | |
| 30 | + | GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-darwin-arm64 ./cmd/zot | |
| 31 | + | GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/zot-windows-amd64.exe ./cmd/zot | |
A README.md +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | # zot | |
| 2 | + | ||
| 3 | + | yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |
| 4 | + | ||
| 5 | + | one static binary. | |
| 6 | + | two providers atm (anthropic, openai/codex). | |
| 7 | + | four tools (read, write, edit, bash). | |
| 8 | + | three run modes (interactive tui, print, json). | |
| 9 | + | no extensions. | |
| 10 | + | no plugins. | |
| 11 | + | no ceremony. | |
| 12 | + | no community. | |
| 13 | + | ||
| 14 | + | ## install | |
| 15 | + | ||
| 16 | + | ```bash | |
| 17 | + | go install github.com/patriceckhart/zot/cmd/zot@latest | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | or from source: | |
| 21 | + | ||
| 22 | + | ```bash | |
| 23 | + | git clone https://github.com/patriceckhart/zot | |
| 24 | + | cd zot | |
| 25 | + | make build # produces ./bin/zot | |
| 26 | + | make install # into $GOPATH/bin | |
| 27 | + | ``` | |
| 28 | + | ||
| 29 | + | ## authenticate | |
| 30 | + | ||
| 31 | + | the easiest way is to just run `zot` and type `/login`. the tui opens even without credentials and walks you through a browser-based login flow. | |
| 32 | + | ||
| 33 | + | ### credential lookup order | |
| 34 | + | ||
| 35 | + | 1. `--api-key` flag | |
| 36 | + | 2. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` env var | |
| 37 | + | 3. `$ZOT_HOME/auth.json` (api key or oauth token; mode 0600) | |
| 38 | + | ||
| 39 | + | `$ZOT_HOME` defaults to: | |
| 40 | + | - macOS: `~/Library/Application Support/zot` | |
| 41 | + | - linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot` | |
| 42 | + | - windows: `%LOCALAPPDATA%\zot` | |
| 43 | + | ||
| 44 | + | ### `/login` flow | |
| 45 | + | ||
| 46 | + | run `zot` and type `/login`. pick one of two methods: | |
| 47 | + | ||
| 48 | + | - **api key** — a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...` or `sk-...` key. zot probes the provider once and saves it to `auth.json` if accepted. | |
| 49 | + | - **subscription** — use your claude pro/max or chatgpt plus/pro subscription. the oauth flow pins the callback to a fixed port per provider (`localhost:53692` for anthropic, `localhost:1455` for openai) because those are the only ports their auth servers will redirect to. | |
| 50 | + | - anthropic uses the claude code oauth flow; messages go to `api.anthropic.com` with a bearer token and the claude-code identity headers. | |
| 51 | + | - openai uses the codex cli oauth flow; messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 52 | + | ||
| 53 | + | > **note on subscription login**: the oauth client ids used are the ones published in anthropic's claude code cli and openai's codex cli. reusing them from a third-party tool is against their terms of service and may be revoked at any time. use it at your own risk; the api-key flow is the safe default. | |
| 54 | + | ||
| 55 | + | all data lives under `$ZOT_HOME`: | |
| 56 | + | ||
| 57 | + | ``` | |
| 58 | + | $ZOT_HOME/ | |
| 59 | + | ├── config.json # last-used provider/model/theme, saved automatically | |
| 60 | + | ├── auth.json # api keys and oauth tokens (mode 0600) | |
| 61 | + | ├── sessions/ # jsonl transcripts, one dir per cwd | |
| 62 | + | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | |
| 63 | + | └── logs/ # app log files | |
| 64 | + | ``` | |
| 65 | + | ||
| 66 | + | ## usage | |
| 67 | + | ||
| 68 | + | ```bash | |
| 69 | + | zot # interactive tui | |
| 70 | + | zot "fix the failing test" # tui, pre-filled prompt | |
| 71 | + | zot -p "list all go files" # print final text, exit | |
| 72 | + | zot --json "refactor main.go" # newline-delimited json events, exit | |
| 73 | + | zot --continue # resume the most recent session for this cwd | |
| 74 | + | zot --resume # pick a session to resume | |
| 75 | + | zot --list-models # show supported models | |
| 76 | + | zot --help | |
| 77 | + | ``` | |
| 78 | + | ||
| 79 | + | ## flags | |
| 80 | + | ||
| 81 | + | | flag | description | | |
| 82 | + | |---|---| | |
| 83 | + | | `--provider anthropic\|openai` | pick the provider | | |
| 84 | + | | `--model <id>` | pick the model (see `--list-models`) | | |
| 85 | + | | `--api-key <key>` | override api key | | |
| 86 | + | | `--base-url <url>` | override provider base url (tests / self-hosted) | | |
| 87 | + | | `--system-prompt <text>` | replace the default system prompt | | |
| 88 | + | | `--append-system-prompt <text>` | append text to the system prompt (repeatable) | | |
| 89 | + | | `--reasoning low\|medium\|high` | enable reasoning on supported models | | |
| 90 | + | | `-c`, `--continue` | resume the latest session for this cwd | | |
| 91 | + | | `-r`, `--resume` | pick a session to resume | | |
| 92 | + | | `--session <path>` | resume a specific session file | | |
| 93 | + | | `--no-session` | don't read or write session files | | |
| 94 | + | | `--cwd <path>` | use `<path>` as the working directory | | |
| 95 | + | | `--no-tools` | disable all tools | | |
| 96 | + | | `--tools <csv>` | only enable the listed tools | | |
| 97 | + | | `--max-steps <n>` | cap agent loop iterations (default 50) | | |
| 98 | + | ||
| 99 | + | ## tools | |
| 100 | + | ||
| 101 | + | - `read` — read text files (or inline images: png / jpg / gif / webp) | |
| 102 | + | - `write` — create or overwrite files, making parent directories as needed | |
| 103 | + | - `edit` — one or more exact-match replacements in an existing file | |
| 104 | + | - `bash` — run a shell command in the session cwd, with merged stdout/stderr and a timeout | |
| 105 | + | ||
| 106 | + | when the sandbox is on (see `/lock`), all four tools refuse paths outside the session cwd. | |
| 107 | + | ||
| 108 | + | ## modes | |
| 109 | + | ||
| 110 | + | - **interactive** (default): chat tui with streaming output, spinner, cost meter, slash commands. | |
| 111 | + | - **print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 112 | + | - **json**: `zot --json "prompt"` emits one json object per agent event to stdout, newline-delimited. the schema is documented in `instructions.md` §8. | |
| 113 | + | ||
| 114 | + | ## slash commands | |
| 115 | + | ||
| 116 | + | type `/` in the tui to open the autocomplete popup. available commands: | |
| 117 | + | ||
| 118 | + | | command | description | | |
| 119 | + | |---|---| | |
| 120 | + | | `/help` | show key bindings and commands | | |
| 121 | + | | `/login` | log in via api key or subscription (opens a dialog) | | |
| 122 | + | | `/logout [provider]` | clear credentials for `anthropic`, `openai`, or all when omitted | | |
| 123 | + | | `/model` | pick a model from a list (or `/model <id>` to set directly) | | |
| 124 | + | | `/sessions` | resume a previous session for this directory | | |
| 125 | + | | `/compact` | summarize the transcript into one message to free up context | | |
| 126 | + | | `/lock` | confine tools to the current directory | | |
| 127 | + | | `/unlock` | allow tools to touch paths outside again | | |
| 128 | + | | `/clear` | clear the chat transcript | | |
| 129 | + | | `/exit` | exit zot | | |
| 130 | + | ||
| 131 | + | ### `/sessions` | |
| 132 | + | ||
| 133 | + | shows previous sessions for the current working directory, newest first, with timestamp, model, message count, cost, and the first user prompt. pick one with `↑/↓`, `enter` to resume, `esc` to cancel. zot swaps the current session file for the selected one and replays the full transcript (including tool calls) into the agent. sessions remember the model they ended on, so resuming picks up on that exact model even if your global default changed. | |
| 134 | + | ||
| 135 | + | ### `/compact` | |
| 136 | + | ||
| 137 | + | sends the current transcript through the model with a structured summarization prompt. the returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. status bar's `ctx N/M (P%)` meter resets. use it when the context meter creeps past ~80%. | |
| 138 | + | ||
| 139 | + | ### `/lock` | |
| 140 | + | ||
| 141 | + | enforces a sandbox rooted at the cwd shown in the status bar. `read` / `write` / `edit` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `bash` refuses obvious escape patterns: `sudo`, `rm -rf /`, leading `cd /` / `cd ..` / `cd ~`, `chmod -R`, `dd of=/`, etc. status bar shows `· locked · ~/your/cwd` while active. | |
| 142 | + | ||
| 143 | + | this is a guardrail against accidents, not a hard security boundary. if you need real isolation, run zot under docker or a proper sandbox. | |
| 144 | + | ||
| 145 | + | ## sessions | |
| 146 | + | ||
| 147 | + | every interactive or print/json run (unless `--no-session`) writes a jsonl transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the tui. | |
| 148 | + | ||
| 149 | + | ## models | |
| 150 | + | ||
| 151 | + | `--list-models` or the `/model` picker shows the full catalog. three sources: | |
| 152 | + | ||
| 153 | + | - **catalog** — models baked into zot, always available | |
| 154 | + | - **live** — ids discovered from `GET /v1/models` using your stored api key (cached for 6h in `$ZOT_HOME/models-cache.json`, refreshed in the background on startup) | |
| 155 | + | - **speculative** — ids that appear in the upstream generator but aren't live on the public api yet; they'll 404 today and start working the moment the provider ships them | |
| 156 | + | ||
| 157 | + | the context meter in the status line (`ctx N/M (P%)`) uses the model's advertised context window to show how much of it your last turn consumed. | |
| 158 | + | ||
| 159 | + | ## inline images | |
| 160 | + | ||
| 161 | + | when a tool returns an image (e.g. `read` on a png), zot renders it inline on terminals that support it: **iterm2**, **wezterm**, **kitty**, **ghostty**. on other terminals you see a text placeholder with mime type, pixel dimensions, and byte size. control with the `ZOT_INLINE_IMAGES` env var: | |
| 162 | + | ||
| 163 | + | | value | effect | | |
| 164 | + | |---|---| | |
| 165 | + | | unset (default) | auto-detect based on `TERM_PROGRAM` | | |
| 166 | + | | `iterm` / `iterm2` | force iterm2 osc 1337 protocol | | |
| 167 | + | | `kitty` | force kitty graphics protocol | | |
| 168 | + | | `off` / `none` | always use the text placeholder | | |
| 169 | + | ||
| 170 | + | frames containing images are full-repainted (no differential diff) to prevent stale image pixels from lingering through scroll. that costs one terminal flash per image-containing frame; set `ZOT_INLINE_IMAGES=off` if that bothers you. | |
| 171 | + | ||
| 172 | + | ## keys (interactive mode) | |
| 173 | + | ||
| 174 | + | ### input | |
| 175 | + | ||
| 176 | + | | key | action | | |
| 177 | + | |---|---| | |
| 178 | + | | `enter` | submit | | |
| 179 | + | | `alt+enter` | newline | | |
| 180 | + | | `tab` | complete the selected slash command | | |
| 181 | + | | `esc` | cancel the current turn (while busy); clear input (while idle) | | |
| 182 | + | | `ctrl+c` | exit when idle; cancel the current turn while busy | | |
| 183 | + | | `ctrl+d` | exit on empty input | | |
| 184 | + | | `ctrl+l` | redraw the screen | | |
| 185 | + | ||
| 186 | + | ### editor line navigation | |
| 187 | + | ||
| 188 | + | | key | action | | |
| 189 | + | |---|---| | |
| 190 | + | | `ctrl+a` / `ctrl+e` | jump to start / end of line | | |
| 191 | + | | `alt+←` / `alt+→` | jump one word back / forward | | |
| 192 | + | | `ctrl+u` / `ctrl+k` | delete to start / end of line | | |
| 193 | + | | `ctrl+w` · `alt+backspace` | delete the previous word | | |
| 194 | + | | `up` / `down` (editor non-empty) | cycle through prompt history | | |
| 195 | + | ||
| 196 | + | ### chat scroll | |
| 197 | + | ||
| 198 | + | | key | action | | |
| 199 | + | |---|---| | |
| 200 | + | | `pgup` / `pgdn` | scroll one page up / down | | |
| 201 | + | | `up` / `down` (editor empty) | scroll three lines up / down — this is how the mouse wheel reaches the scroll logic on most terminals | | |
| 202 | + | ||
| 203 | + | ## development | |
| 204 | + | ||
| 205 | + | ```bash | |
| 206 | + | make build # build ./bin/zot | |
| 207 | + | make test # go test -race ./... | |
| 208 | + | make lint # go vet + gofmt check | |
| 209 | + | make fmt # gofmt -w . | |
| 210 | + | make release # cross-compile linux/darwin/windows × amd64/arm64 | |
| 211 | + | ``` | |
| 212 | + | ||
| 213 | + | source layout: | |
| 214 | + | ||
| 215 | + | ``` | |
| 216 | + | cmd/zot/ main() | |
| 217 | + | internal/agent/ cli wiring, arg parsing, system prompt, config | |
| 218 | + | internal/agent/modes/ interactive tui, print, json, dialogs | |
| 219 | + | internal/agent/tools/ read, write, edit, bash, sandbox | |
| 220 | + | internal/auth/ credential store, api-key probe, oauth, login server | |
| 221 | + | internal/core/ agent loop, sessions, cost tracking | |
| 222 | + | internal/provider/ anthropic + openai streaming clients, model catalog | |
| 223 | + | internal/tui/ terminal raw-mode, input parser, editor, renderer, markdown, view | |
| 224 | + | ``` | |
| 225 | + | ||
| 226 | + | ## license | |
| 227 | + | ||
| 228 | + | MIT | |
A cmd/zot/main.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | // Command zot is a lightweight terminal coding agent. | |
| 2 | + | package main | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/agent" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var version = "dev" | |
| 12 | + | ||
| 13 | + | func main() { | |
| 14 | + | if err := agent.Run(os.Args[1:], version); err != nil { | |
| 15 | + | fmt.Fprintln(os.Stderr, "zot:", err) | |
| 16 | + | os.Exit(1) | |
| 17 | + | } | |
| 18 | + | } | |
A go.mod +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | module github.com/patriceckhart/zot | |
| 2 | + | ||
| 3 | + | go 1.22 | |
| 4 | + | ||
| 5 | + | require ( | |
| 6 | + | github.com/google/uuid v1.6.0 | |
| 7 | + | github.com/mattn/go-runewidth v0.0.16 | |
| 8 | + | golang.org/x/term v0.25.0 | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | require ( | |
| 12 | + | github.com/rivo/uniseg v0.2.0 // indirect | |
| 13 | + | golang.org/x/sys v0.26.0 // indirect | |
| 14 | + | ) | |
A go.sum +14 -0 generated file: collapsed
A internal/agent/args.go +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Mode is the CLI run mode. | |
| 10 | + | type Mode string | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | ModeInteractive Mode = "interactive" | |
| 14 | + | ModePrint Mode = "print" | |
| 15 | + | ModeJSON Mode = "json" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Args holds parsed command-line options. | |
| 19 | + | type Args struct { | |
| 20 | + | Mode Mode | |
| 21 | + | Provider string | |
| 22 | + | Model string | |
| 23 | + | APIKey string | |
| 24 | + | ||
| 25 | + | BaseURL string // override provider base URL (for tests/self-hosted) | |
| 26 | + | SystemPrompt string | |
| 27 | + | AppendSystemPrompt []string | |
| 28 | + | Reasoning string | |
| 29 | + | ||
| 30 | + | Continue bool | |
| 31 | + | Resume bool | |
| 32 | + | Session string | |
| 33 | + | NoSess bool | |
| 34 | + | ||
| 35 | + | CWD string | |
| 36 | + | NoTools bool | |
| 37 | + | Tools []string | |
| 38 | + | MaxSteps int | |
| 39 | + | ||
| 40 | + | ListModels bool | |
| 41 | + | Help bool | |
| 42 | + | Version bool | |
| 43 | + | ||
| 44 | + | Prompt string // concatenated positional args | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // ParseArgs parses the process arguments (excluding argv[0]). | |
| 48 | + | func ParseArgs(in []string) (Args, error) { | |
| 49 | + | a := Args{Mode: ModeInteractive, MaxSteps: 50} | |
| 50 | + | positional := []string{} | |
| 51 | + | ||
| 52 | + | want := func(i *int, flag string) (string, error) { | |
| 53 | + | *i++ | |
| 54 | + | if *i >= len(in) { | |
| 55 | + | return "", fmt.Errorf("%s requires a value", flag) | |
| 56 | + | } | |
| 57 | + | return in[*i], nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | for i := 0; i < len(in); i++ { | |
| 61 | + | arg := in[i] | |
| 62 | + | switch arg { | |
| 63 | + | case "-h", "--help": | |
| 64 | + | a.Help = true | |
| 65 | + | case "-v", "--version": | |
| 66 | + | a.Version = true | |
| 67 | + | case "-p", "--print": | |
| 68 | + | a.Mode = ModePrint | |
| 69 | + | case "--json": | |
| 70 | + | a.Mode = ModeJSON | |
| 71 | + | case "-c", "--continue": | |
| 72 | + | a.Continue = true | |
| 73 | + | case "-r", "--resume": | |
| 74 | + | a.Resume = true | |
| 75 | + | case "--no-session": | |
| 76 | + | a.NoSess = true | |
| 77 | + | case "--no-tools": | |
| 78 | + | a.NoTools = true | |
| 79 | + | case "--list-models": | |
| 80 | + | a.ListModels = true | |
| 81 | + | case "--experimental-oauth": | |
| 82 | + | // deprecated: subscription login is always available. | |
| 83 | + | // accepted silently for backwards compatibility. | |
| 84 | + | case "--provider": | |
| 85 | + | v, err := want(&i, arg) | |
| 86 | + | if err != nil { | |
| 87 | + | return a, err | |
| 88 | + | } | |
| 89 | + | a.Provider = v | |
| 90 | + | case "--model": | |
| 91 | + | v, err := want(&i, arg) | |
| 92 | + | if err != nil { | |
| 93 | + | return a, err | |
| 94 | + | } | |
| 95 | + | a.Model = v | |
| 96 | + | case "--api-key": | |
| 97 | + | v, err := want(&i, arg) | |
| 98 | + | if err != nil { | |
| 99 | + | return a, err | |
| 100 | + | } | |
| 101 | + | a.APIKey = v | |
| 102 | + | case "--base-url": | |
| 103 | + | v, err := want(&i, arg) | |
| 104 | + | if err != nil { | |
| 105 | + | return a, err | |
| 106 | + | } | |
| 107 | + | a.BaseURL = v | |
| 108 | + | case "--system-prompt": | |
| 109 | + | v, err := want(&i, arg) | |
| 110 | + | if err != nil { | |
| 111 | + | return a, err | |
| 112 | + | } | |
| 113 | + | a.SystemPrompt = v | |
| 114 | + | case "--append-system-prompt": | |
| 115 | + | v, err := want(&i, arg) | |
| 116 | + | if err != nil { | |
| 117 | + | return a, err | |
| 118 | + | } | |
| 119 | + | a.AppendSystemPrompt = append(a.AppendSystemPrompt, v) | |
| 120 | + | case "--reasoning": | |
| 121 | + | v, err := want(&i, arg) | |
| 122 | + | if err != nil { | |
| 123 | + | return a, err | |
| 124 | + | } | |
| 125 | + | switch strings.ToLower(v) { | |
| 126 | + | case "", "low", "medium", "high": | |
| 127 | + | a.Reasoning = strings.ToLower(v) | |
| 128 | + | default: | |
| 129 | + | return a, fmt.Errorf("--reasoning must be low|medium|high") | |
| 130 | + | } | |
| 131 | + | case "--session": | |
| 132 | + | v, err := want(&i, arg) | |
| 133 | + | if err != nil { | |
| 134 | + | return a, err | |
| 135 | + | } | |
| 136 | + | a.Session = v | |
| 137 | + | case "--cwd": | |
| 138 | + | v, err := want(&i, arg) | |
| 139 | + | if err != nil { | |
| 140 | + | return a, err | |
| 141 | + | } | |
| 142 | + | a.CWD = v | |
| 143 | + | case "--tools": | |
| 144 | + | v, err := want(&i, arg) | |
| 145 | + | if err != nil { | |
| 146 | + | return a, err | |
| 147 | + | } | |
| 148 | + | for _, t := range strings.Split(v, ",") { | |
| 149 | + | t = strings.TrimSpace(t) | |
| 150 | + | if t != "" { | |
| 151 | + | a.Tools = append(a.Tools, t) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | case "--max-steps": | |
| 155 | + | v, err := want(&i, arg) | |
| 156 | + | if err != nil { | |
| 157 | + | return a, err | |
| 158 | + | } | |
| 159 | + | var n int | |
| 160 | + | if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 { | |
| 161 | + | return a, fmt.Errorf("--max-steps must be a positive integer") | |
| 162 | + | } | |
| 163 | + | a.MaxSteps = n | |
| 164 | + | default: | |
| 165 | + | if strings.HasPrefix(arg, "-") && arg != "-" { | |
| 166 | + | return a, fmt.Errorf("unknown flag %q", arg) | |
| 167 | + | } | |
| 168 | + | positional = append(positional, arg) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | if len(positional) > 0 { | |
| 173 | + | a.Prompt = strings.Join(positional, " ") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | if a.CWD == "" { | |
| 177 | + | a.CWD, _ = os.Getwd() | |
| 178 | + | } | |
| 179 | + | return a, nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // PrintHelp writes the help text to w (stderr by default). | |
| 183 | + | func PrintHelp(version string) { | |
| 184 | + | fmt.Fprintf(os.Stderr, `zot %s — lightweight terminal coding agent | |
| 185 | + | ||
| 186 | + | usage: | |
| 187 | + | zot interactive tui | |
| 188 | + | zot "prompt" interactive, pre-filled prompt | |
| 189 | + | zot -p "prompt" print final text, exit | |
| 190 | + | zot --json "prompt" newline-delimited json events, exit | |
| 191 | + | ||
| 192 | + | flags: | |
| 193 | + | --provider anthropic|openai | |
| 194 | + | --model model id (see --list-models) | |
| 195 | + | --api-key api key for this run (falls back to env and auth.json) | |
| 196 | + | --base-url override provider api base url (for testing/self-hosted) | |
| 197 | + | ||
| 198 | + | --system-prompt TEXT replace the default system prompt | |
| 199 | + | --append-system-prompt TEXT append to the system prompt (repeatable) | |
| 200 | + | --reasoning low|medium|high enable reasoning on supported models | |
| 201 | + | ||
| 202 | + | -c, --continue continue the most recent session for this cwd | |
| 203 | + | -r, --resume pick a session to resume | |
| 204 | + | --session PATH resume a specific session file | |
| 205 | + | --no-session do not read or write a session file | |
| 206 | + | ||
| 207 | + | --cwd PATH treat PATH as the working directory | |
| 208 | + | --no-tools disable all tools | |
| 209 | + | --tools csv only enable the listed tools | |
| 210 | + | ||
| 211 | + | --max-steps N agent loop iteration cap (default 50) | |
| 212 | + | --list-models print known models and exit | |
| 213 | + | -h, --help this message | |
| 214 | + | -v, --version version info | |
| 215 | + | `, version) | |
| 216 | + | } | |
A internal/agent/build.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 7 | + | "github.com/patriceckhart/zot/internal/core" | |
| 8 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Resolved is the effective configuration after merging CLI, config, defaults. | |
| 12 | + | type Resolved struct { | |
| 13 | + | Provider string | |
| 14 | + | Model string | |
| 15 | + | Credential string // api key or oauth access token | |
| 16 | + | AuthMethod string // "apikey" | "oauth" | "" (no credential yet) | |
| 17 | + | AccountID string // ChatGPT account id (for openai oauth), "" otherwise | |
| 18 | + | BaseURL string | |
| 19 | + | CWD string | |
| 20 | + | Reasoning string | |
| 21 | + | ||
| 22 | + | ToolRegistry core.Registry | |
| 23 | + | ToolSummary []ToolSummary | |
| 24 | + | SystemPrompt string | |
| 25 | + | MaxSteps int | |
| 26 | + | Sandbox *tools.Sandbox | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // HasCredential reports whether a credential was resolved. | |
| 30 | + | func (r Resolved) HasCredential() bool { return r.Credential != "" } | |
| 31 | + | ||
| 32 | + | // Resolve merges args, config, and env into a Resolved set. | |
| 33 | + | // | |
| 34 | + | // Unlike the earlier version, Resolve NEVER returns an error for | |
| 35 | + | // missing credentials: the TUI can start without them and launch a | |
| 36 | + | // login flow. requireCred controls whether missing credentials are a | |
| 37 | + | // hard error (used by print/json modes). | |
| 38 | + | func Resolve(args Args, requireCred bool) (Resolved, error) { | |
| 39 | + | cfg, _ := LoadConfig() | |
| 40 | + | ||
| 41 | + | // User-requested provider (explicit > config > default). | |
| 42 | + | provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic") | |
| 43 | + | if provName != "anthropic" && provName != "openai" { | |
| 44 | + | return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Try the requested provider first. | |
| 48 | + | cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey) | |
| 49 | + | ||
| 50 | + | // If the user did NOT explicitly pick a provider and the default one | |
| 51 | + | // has no credentials, auto-fall-back to whichever provider is actually | |
| 52 | + | // logged in. That way running plain `zot` after `/login` (any provider) | |
| 53 | + | // never shows a "not logged in" banner. | |
| 54 | + | userPickedProvider := args.Provider != "" | |
| 55 | + | if credErr != nil && !userPickedProvider { | |
| 56 | + | other := "openai" | |
| 57 | + | if provName == "openai" { | |
| 58 | + | other = "anthropic" | |
| 59 | + | } | |
| 60 | + | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | |
| 61 | + | provName = other | |
| 62 | + | cred, method, accountID, credErr = c, m, a, err | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | model := firstNonEmpty(args.Model, cfg.Model) | |
| 67 | + | if model == "" { | |
| 68 | + | if provName == "openai" { | |
| 69 | + | model = "gpt-5" | |
| 70 | + | } else { | |
| 71 | + | model = provider.DefaultModel.ID | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | // If the resolved model belongs to a different provider (e.g. config | |
| 75 | + | // says gpt-5 but we fell back to anthropic), pick that provider's default. | |
| 76 | + | if m, err := provider.FindModel("", model); err == nil && m.Provider != provName { | |
| 77 | + | if provName == "openai" { | |
| 78 | + | model = "gpt-5" | |
| 79 | + | } else { | |
| 80 | + | model = provider.DefaultModel.ID | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | if _, err := provider.FindModel(provName, model); err != nil { | |
| 84 | + | return Resolved{}, err | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if credErr != nil && requireCred { | |
| 88 | + | return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login", | |
| 89 | + | credErr, envVarName(provName)) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | sandbox := tools.NewSandbox(args.CWD) | |
| 93 | + | reg := buildToolRegistry(args, args.CWD, sandbox) | |
| 94 | + | summaries := toolSummaries(reg, args) | |
| 95 | + | ||
| 96 | + | sys := BuildSystemPrompt(SystemPromptOpts{ | |
| 97 | + | CWD: args.CWD, | |
| 98 | + | Tools: summaries, | |
| 99 | + | Custom: args.SystemPrompt, | |
| 100 | + | Append: args.AppendSystemPrompt, | |
| 101 | + | }) | |
| 102 | + | ||
| 103 | + | reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning) | |
| 104 | + | ||
| 105 | + | max := args.MaxSteps | |
| 106 | + | if max <= 0 { | |
| 107 | + | max = 50 | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | return Resolved{ | |
| 111 | + | Provider: provName, | |
| 112 | + | Model: model, | |
| 113 | + | Credential: cred, | |
| 114 | + | AuthMethod: method, | |
| 115 | + | AccountID: accountID, | |
| 116 | + | BaseURL: args.BaseURL, | |
| 117 | + | CWD: args.CWD, | |
| 118 | + | Reasoning: reasoning, | |
| 119 | + | ToolRegistry: reg, | |
| 120 | + | ToolSummary: summaries, | |
| 121 | + | SystemPrompt: sys, | |
| 122 | + | MaxSteps: max, | |
| 123 | + | Sandbox: sandbox, | |
| 124 | + | }, nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // NewClient returns a provider.Client for r, choosing the auth mode | |
| 128 | + | // based on r.AuthMethod. Panics if no credential is present; callers | |
| 129 | + | // must check HasCredential() first. | |
| 130 | + | func (r Resolved) NewClient() provider.Client { | |
| 131 | + | if !r.HasCredential() { | |
| 132 | + | panic("NewClient called without credential; check HasCredential first") | |
| 133 | + | } | |
| 134 | + | if r.Provider == "openai" { | |
| 135 | + | if r.AuthMethod == "oauth" { | |
| 136 | + | return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| 137 | + | } | |
| 138 | + | return provider.NewOpenAI(r.Credential, r.BaseURL) | |
| 139 | + | } | |
| 140 | + | if r.AuthMethod == "oauth" { | |
| 141 | + | return provider.NewAnthropicOAuth(r.Credential, r.BaseURL) | |
| 142 | + | } | |
| 143 | + | return provider.NewAnthropic(r.Credential, r.BaseURL) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // UseSandbox replaces the sandbox pointer that every tool in r's | |
| 147 | + | // registry references. Used to keep the /lock state stable across | |
| 148 | + | // agent rebuilds (e.g. /login, /model switching providers). | |
| 149 | + | func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |
| 150 | + | if s == nil || r == nil { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | r.Sandbox = s | |
| 154 | + | for name, t := range r.ToolRegistry { | |
| 155 | + | switch v := t.(type) { | |
| 156 | + | case *tools.ReadTool: | |
| 157 | + | v.Sandbox = s | |
| 158 | + | case *tools.WriteTool: | |
| 159 | + | v.Sandbox = s | |
| 160 | + | case *tools.EditTool: | |
| 161 | + | v.Sandbox = s | |
| 162 | + | case *tools.BashTool: | |
| 163 | + | v.Sandbox = s | |
| 164 | + | } | |
| 165 | + | _ = name | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // NewAgent constructs a core.Agent from r. Requires a credential. | |
| 170 | + | func (r Resolved) NewAgent() *core.Agent { | |
| 171 | + | a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry) | |
| 172 | + | a.MaxSteps = r.MaxSteps | |
| 173 | + | a.Reasoning = r.Reasoning | |
| 174 | + | return a | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | |
| 178 | + | if args.NoTools { | |
| 179 | + | return core.Registry{} | |
| 180 | + | } | |
| 181 | + | all := map[string]core.Tool{ | |
| 182 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 183 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox}, | |
| 184 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox}, | |
| 185 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 186 | + | } | |
| 187 | + | reg := core.Registry{} | |
| 188 | + | if len(args.Tools) == 0 { | |
| 189 | + | for _, t := range all { | |
| 190 | + | reg[t.Name()] = t | |
| 191 | + | } | |
| 192 | + | return reg | |
| 193 | + | } | |
| 194 | + | for _, name := range args.Tools { | |
| 195 | + | if t, ok := all[name]; ok { | |
| 196 | + | reg[name] = t | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return reg | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 203 | + | order := []string{"read", "write", "edit", "bash"} | |
| 204 | + | var out []ToolSummary | |
| 205 | + | for _, name := range order { | |
| 206 | + | if t, ok := reg[name]; ok { | |
| 207 | + | out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()}) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return out | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func firstNonEmpty(vals ...string) string { | |
| 214 | + | for _, v := range vals { | |
| 215 | + | if v != "" { | |
| 216 | + | return v | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | return "" | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func envVarName(provider string) string { | |
| 223 | + | switch provider { | |
| 224 | + | case "openai": | |
| 225 | + | return "OPENAI" | |
| 226 | + | default: | |
| 227 | + | return "ANTHROPIC" | |
| 228 | + | } | |
| 229 | + | } | |
A internal/agent/cli.go +338 -0
| @@ -0,0 +1,338 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 12 | + | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | + | "github.com/patriceckhart/zot/internal/core" | |
| 14 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 15 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Run is the top-level entrypoint for the zot binary. | |
| 19 | + | func Run(rawArgs []string, version string) error { | |
| 20 | + | args, err := ParseArgs(rawArgs) | |
| 21 | + | if err != nil { | |
| 22 | + | PrintHelp(version) | |
| 23 | + | return err | |
| 24 | + | } | |
| 25 | + | if args.Help { | |
| 26 | + | PrintHelp(version) | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | if args.Version { | |
| 30 | + | fmt.Println("zot", version) | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | // Model catalog: load any cached discovery data before we inspect | |
| 34 | + | // the model list (list-models, print/json, interactive). | |
| 35 | + | LoadCachedModels() | |
| 36 | + | ||
| 37 | + | if args.ListModels { | |
| 38 | + | printModels() | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | ctx := context.Background() | |
| 43 | + | ||
| 44 | + | // Kick an async refresh of the live model catalog. The first run of | |
| 45 | + | // zot hits the network; subsequent runs within CacheTTL do nothing. | |
| 46 | + | RefreshModelsAsync() | |
| 47 | + | ||
| 48 | + | switch args.Mode { | |
| 49 | + | case ModePrint: | |
| 50 | + | return runPrintMode(ctx, args, version) | |
| 51 | + | case ModeJSON: | |
| 52 | + | return runJSONMode(ctx, args, version) | |
| 53 | + | default: | |
| 54 | + | return runInteractive(ctx, args, version) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // ---- print / json modes: require credentials, run single-shot ---- | |
| 59 | + | ||
| 60 | + | func runPrintMode(ctx context.Context, args Args, version string) error { | |
| 61 | + | r, err := Resolve(args, true) | |
| 62 | + | if err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | ag := r.NewAgent() | |
| 66 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 67 | + | defer sess.Close() | |
| 68 | + | ||
| 69 | + | prompt := args.Prompt | |
| 70 | + | if prompt == "" { | |
| 71 | + | piped, _ := readAllStdin() | |
| 72 | + | prompt = strings.TrimSpace(piped) | |
| 73 | + | } | |
| 74 | + | if prompt == "" { | |
| 75 | + | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | start := len(ag.Messages()) | |
| 79 | + | err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | |
| 80 | + | WriteNewTranscript(ag, sess, start) | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func runJSONMode(ctx context.Context, args Args, version string) error { | |
| 85 | + | r, err := Resolve(args, true) | |
| 86 | + | if err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | ag := r.NewAgent() | |
| 90 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 91 | + | defer sess.Close() | |
| 92 | + | ||
| 93 | + | prompt := args.Prompt | |
| 94 | + | if prompt == "" { | |
| 95 | + | piped, _ := readAllStdin() | |
| 96 | + | prompt = strings.TrimSpace(piped) | |
| 97 | + | } | |
| 98 | + | if prompt == "" { | |
| 99 | + | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | start := len(ag.Messages()) | |
| 103 | + | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | |
| 104 | + | WriteNewTranscript(ag, sess, start) | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // ---- interactive mode: opens the TUI even without credentials ---- | |
| 109 | + | ||
| 110 | + | func runInteractive(ctx context.Context, args Args, version string) error { | |
| 111 | + | // Resolve WITHOUT requiring credentials. | |
| 112 | + | r, err := Resolve(args, false) | |
| 113 | + | if err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | authStore := AuthStoreFor() | |
| 118 | + | mgr := auth.NewManager(authStore) | |
| 119 | + | defer mgr.Close() | |
| 120 | + | ||
| 121 | + | // Keep the sandbox pointer stable across agent rebuilds (login / model | |
| 122 | + | // switch). The Interactive UI toggles the lock via this pointer, and | |
| 123 | + | // rebuilt tool instances must share the same one so the lock sticks. | |
| 124 | + | sharedSandbox := r.Sandbox | |
| 125 | + | ||
| 126 | + | // Capture current args in a closure so BuildAgent can re-resolve | |
| 127 | + | // after a successful login (picks up the newly stored credential). | |
| 128 | + | buildAgent := func() (*core.Agent, string, string, error) { | |
| 129 | + | resolved, err := Resolve(args, true) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, "", "", err | |
| 132 | + | } | |
| 133 | + | resolved.UseSandbox(sharedSandbox) | |
| 134 | + | return resolved.NewAgent(), resolved.Provider, resolved.Model, nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Rebuild agent with an explicit provider/model override. | |
| 138 | + | buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) { | |
| 139 | + | next := args | |
| 140 | + | if providerOverride != "" { | |
| 141 | + | next.Provider = providerOverride | |
| 142 | + | } | |
| 143 | + | if modelOverride != "" { | |
| 144 | + | next.Model = modelOverride | |
| 145 | + | } | |
| 146 | + | resolved, err := Resolve(next, true) | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, "", "", err | |
| 149 | + | } | |
| 150 | + | resolved.UseSandbox(sharedSandbox) | |
| 151 | + | return resolved.NewAgent(), resolved.Provider, resolved.Model, nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | var ag *core.Agent | |
| 155 | + | if r.HasCredential() { | |
| 156 | + | ag = r.NewAgent() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | var sess *core.Session | |
| 160 | + | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 161 | + | if !args.NoSess && ag != nil { | |
| 162 | + | sess, _ = openOrCreateSession(args, r, ag, version) | |
| 163 | + | if ag != nil { | |
| 164 | + | sessBaselineMsgs = len(ag.Messages()) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | defer func() { | |
| 168 | + | if sess != nil { | |
| 169 | + | sess.Close() | |
| 170 | + | } | |
| 171 | + | }() | |
| 172 | + | ||
| 173 | + | // loadSession replaces the current session with the one at path and | |
| 174 | + | // hands its messages to the agent. Used by the /sessions picker. | |
| 175 | + | loadSession := func(path string) error { | |
| 176 | + | currentAg := ag // captured | |
| 177 | + | if currentAg == nil { | |
| 178 | + | return fmt.Errorf("no agent running; log in first") | |
| 179 | + | } | |
| 180 | + | newSess, msgs, err := core.OpenSession(path) | |
| 181 | + | if err != nil { | |
| 182 | + | return err | |
| 183 | + | } | |
| 184 | + | // Flush any unsaved messages to the old session before swapping. | |
| 185 | + | if sess != nil { | |
| 186 | + | WriteNewTranscript(currentAg, sess, sessBaselineMsgs) | |
| 187 | + | _ = sess.Close() | |
| 188 | + | } | |
| 189 | + | sess = newSess | |
| 190 | + | currentAg.SetMessages(msgs) | |
| 191 | + | sessBaselineMsgs = len(msgs) | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | term := tui.NewProcTerm() | |
| 196 | + | iv := modes.NewInteractive(modes.InteractiveConfig{ | |
| 197 | + | Terminal: term, | |
| 198 | + | Theme: tui.Dark, | |
| 199 | + | Model: r.Model, | |
| 200 | + | Provider: r.Provider, | |
| 201 | + | BaseURL: r.BaseURL, | |
| 202 | + | Reasoning: r.Reasoning, | |
| 203 | + | SystemPrompt: r.SystemPrompt, | |
| 204 | + | Tools: r.ToolRegistry, | |
| 205 | + | MaxSteps: r.MaxSteps, | |
| 206 | + | CWD: r.CWD, | |
| 207 | + | ZotHome: ZotHome(), | |
| 208 | + | Sandbox: sharedSandbox, | |
| 209 | + | Agent: ag, | |
| 210 | + | InitialInput: args.Prompt, | |
| 211 | + | AuthManager: mgr, | |
| 212 | + | BuildAgent: buildAgent, | |
| 213 | + | BuildAgentFor: buildAgentFor, | |
| 214 | + | LoadSession: loadSession, | |
| 215 | + | PersistModel: func(providerName, model string) { | |
| 216 | + | // Update config.json so next launch uses the same pick. | |
| 217 | + | cfg, _ := LoadConfig() | |
| 218 | + | cfg.Provider = providerName | |
| 219 | + | cfg.Model = model | |
| 220 | + | _ = SaveConfig(cfg) | |
| 221 | + | // Update the active session's meta so resume picks this up. | |
| 222 | + | if sess != nil { | |
| 223 | + | _ = sess.UpdateModel(providerName, model) | |
| 224 | + | } | |
| 225 | + | }, | |
| 226 | + | }) | |
| 227 | + | ||
| 228 | + | runErr := iv.Run(ctx) | |
| 229 | + | ||
| 230 | + | // Flush final transcript to session (only if we had / ended up with an agent). | |
| 231 | + | if finalAg := iv.Agent(); finalAg != nil && sess != nil { | |
| 232 | + | WriteNewTranscript(finalAg, sess, sessBaselineMsgs) | |
| 233 | + | } | |
| 234 | + | return runErr | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // openOrCreateSession returns a session for the run. sess may be nil | |
| 238 | + | // with a nil error if session persistence is disabled. | |
| 239 | + | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 240 | + | if args.NoSess { | |
| 241 | + | return nil, nil | |
| 242 | + | } | |
| 243 | + | var ( | |
| 244 | + | s *core.Session | |
| 245 | + | msgs []provider.Message | |
| 246 | + | err error | |
| 247 | + | ) | |
| 248 | + | switch { | |
| 249 | + | case args.Session != "": | |
| 250 | + | s, msgs, err = core.OpenSession(args.Session) | |
| 251 | + | case args.Continue: | |
| 252 | + | latest := core.LatestSession(ZotHome(), args.CWD) | |
| 253 | + | if latest != "" { | |
| 254 | + | s, msgs, err = core.OpenSession(latest) | |
| 255 | + | } | |
| 256 | + | case args.Resume: | |
| 257 | + | picked, perr := pickSession(args.CWD) | |
| 258 | + | if perr != nil { | |
| 259 | + | return nil, perr | |
| 260 | + | } | |
| 261 | + | if picked != "" { | |
| 262 | + | s, msgs, err = core.OpenSession(picked) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | if err != nil { | |
| 266 | + | return nil, err | |
| 267 | + | } | |
| 268 | + | if s != nil { | |
| 269 | + | ag.SetMessages(msgs) | |
| 270 | + | return s, nil | |
| 271 | + | } | |
| 272 | + | return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func pickSession(cwd string) (string, error) { | |
| 276 | + | files := core.ListSessions(ZotHome(), cwd) | |
| 277 | + | if len(files) == 0 { | |
| 278 | + | fmt.Fprintln(os.Stderr, "no sessions for", cwd) | |
| 279 | + | return "", nil | |
| 280 | + | } | |
| 281 | + | for i, f := range files { | |
| 282 | + | fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f) | |
| 283 | + | } | |
| 284 | + | fmt.Fprint(os.Stderr, "pick #: ") | |
| 285 | + | rd := bufio.NewReader(os.Stdin) | |
| 286 | + | line, _ := rd.ReadString('\n') | |
| 287 | + | line = strings.TrimSpace(line) | |
| 288 | + | var n int | |
| 289 | + | if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) { | |
| 290 | + | return "", fmt.Errorf("invalid selection") | |
| 291 | + | } | |
| 292 | + | return files[n-1], nil | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // WriteNewTranscript appends only messages after index `from` from the | |
| 296 | + | // agent's transcript to the session. | |
| 297 | + | func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) { | |
| 298 | + | if sess == nil || ag == nil { | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | msgs := ag.Messages() | |
| 302 | + | for i := from; i < len(msgs); i++ { | |
| 303 | + | _ = sess.AppendMessage(msgs[i]) | |
| 304 | + | } | |
| 305 | + | cum := ag.Cost() | |
| 306 | + | _ = sess.AppendUsage(cum, cum) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func readAllStdin() (string, error) { | |
| 310 | + | fi, err := os.Stdin.Stat() | |
| 311 | + | if err != nil { | |
| 312 | + | return "", err | |
| 313 | + | } | |
| 314 | + | if (fi.Mode() & os.ModeCharDevice) != 0 { | |
| 315 | + | return "", nil | |
| 316 | + | } | |
| 317 | + | b, err := io.ReadAll(os.Stdin) | |
| 318 | + | return string(b), err | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func printModels() { | |
| 322 | + | fmt.Println("provider model id context max-out reasoning source name") | |
| 323 | + | for _, m := range provider.Active() { | |
| 324 | + | reason := " " | |
| 325 | + | if m.Reasoning { | |
| 326 | + | reason = "✓" | |
| 327 | + | } | |
| 328 | + | source := m.Source | |
| 329 | + | if source == "" { | |
| 330 | + | source = "catalog" | |
| 331 | + | } | |
| 332 | + | if m.Speculative { | |
| 333 | + | source = "speculative" | |
| 334 | + | } | |
| 335 | + | fmt.Printf("%-10s %-30s %8d %8d %s %-11s %s\n", | |
| 336 | + | m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName) | |
| 337 | + | } | |
| 338 | + | } | |
A internal/agent/config.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | // Package agent wires the provider, core, tools, auth, and modes into a CLI. | |
| 2 | + | package agent | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "runtime" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Config is the persisted user configuration. | |
| 16 | + | type Config struct { | |
| 17 | + | Provider string `json:"provider"` | |
| 18 | + | Model string `json:"model"` | |
| 19 | + | Reasoning string `json:"reasoning"` | |
| 20 | + | Theme string `json:"theme"` | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ZotHome returns $ZOT_HOME or the OS-default data dir. | |
| 24 | + | // | |
| 25 | + | // All zot state (config.json, auth.json, sessions/, logs/) lives under | |
| 26 | + | // this directory. | |
| 27 | + | func ZotHome() string { | |
| 28 | + | if v := os.Getenv("ZOT_HOME"); v != "" { | |
| 29 | + | return v | |
| 30 | + | } | |
| 31 | + | switch runtime.GOOS { | |
| 32 | + | case "darwin": | |
| 33 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 34 | + | return filepath.Join(home, "Library", "Application Support", "zot") | |
| 35 | + | } | |
| 36 | + | case "windows": | |
| 37 | + | if v := os.Getenv("LOCALAPPDATA"); v != "" { | |
| 38 | + | return filepath.Join(v, "zot") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | if v := os.Getenv("XDG_STATE_HOME"); v != "" { | |
| 42 | + | return filepath.Join(v, "zot") | |
| 43 | + | } | |
| 44 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 45 | + | return filepath.Join(home, ".local", "state", "zot") | |
| 46 | + | } | |
| 47 | + | return ".zot" | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ConfigPath returns the path to config.json. | |
| 51 | + | func ConfigPath() string { return filepath.Join(ZotHome(), "config.json") } | |
| 52 | + | ||
| 53 | + | // AuthPath returns the path to auth.json. | |
| 54 | + | func AuthPath() string { return filepath.Join(ZotHome(), "auth.json") } | |
| 55 | + | ||
| 56 | + | // SessionsPath returns the directory holding session files. | |
| 57 | + | func SessionsPath() string { return filepath.Join(ZotHome(), "sessions") } | |
| 58 | + | ||
| 59 | + | // LogsPath returns the directory holding log files. | |
| 60 | + | func LogsPath() string { return filepath.Join(ZotHome(), "logs") } | |
| 61 | + | ||
| 62 | + | // LoadConfig reads the config file, returning defaults if missing. | |
| 63 | + | func LoadConfig() (Config, error) { | |
| 64 | + | var c Config | |
| 65 | + | b, err := os.ReadFile(ConfigPath()) | |
| 66 | + | if errors.Is(err, os.ErrNotExist) { | |
| 67 | + | return c, nil | |
| 68 | + | } | |
| 69 | + | if err != nil { | |
| 70 | + | return c, err | |
| 71 | + | } | |
| 72 | + | if err := json.Unmarshal(b, &c); err != nil { | |
| 73 | + | return c, fmt.Errorf("parse config: %w", err) | |
| 74 | + | } | |
| 75 | + | return c, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // SaveConfig writes the config file, creating parent dirs. | |
| 79 | + | func SaveConfig(c Config) error { | |
| 80 | + | if err := os.MkdirAll(ZotHome(), 0o755); err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | b, err := json.MarshalIndent(c, "", " ") | |
| 84 | + | if err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | return os.WriteFile(ConfigPath(), b, 0o644) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // AuthStoreFor returns the auth.Store backed by AuthPath(). | |
| 91 | + | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | |
| 92 | + | ||
| 93 | + | // ResolveCredential returns the credential (api key or oauth access | |
| 94 | + | // token), the method ("apikey"/"oauth"), and an error when no | |
| 95 | + | // credential is available. | |
| 96 | + | // | |
| 97 | + | // Lookup order: | |
| 98 | + | // 1. explicit (e.g. --api-key): treated as API key | |
| 99 | + | // 2. provider-specific env var: treated as API key | |
| 100 | + | // 3. auth.json: api key OR oauth, whichever is present | |
| 101 | + | func ResolveCredential(provider, explicit string) (cred, method string, err error) { | |
| 102 | + | cred, method, _, err = ResolveCredentialFull(provider, explicit) | |
| 103 | + | return cred, method, err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // ResolveCredentialFull is like ResolveCredential but also returns a | |
| 107 | + | // provider-specific accountID when the credential is an OpenAI OAuth | |
| 108 | + | // token (the ChatGPT account id extracted from the stored id_token). | |
| 109 | + | // accountID is "" for API-key auth and for anthropic. | |
| 110 | + | func ResolveCredentialFull(provider, explicit string) (cred, method, accountID string, err error) { | |
| 111 | + | if explicit != "" { | |
| 112 | + | return explicit, "apikey", "", nil | |
| 113 | + | } | |
| 114 | + | switch provider { | |
| 115 | + | case "anthropic": | |
| 116 | + | if v := os.Getenv("ANTHROPIC_API_KEY"); v != "" { | |
| 117 | + | return v, "apikey", "", nil | |
| 118 | + | } | |
| 119 | + | case "openai": | |
| 120 | + | if v := os.Getenv("OPENAI_API_KEY"); v != "" { | |
| 121 | + | return v, "apikey", "", nil | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | c, err := AuthStoreFor().Load() | |
| 125 | + | if err != nil { | |
| 126 | + | return "", "", "", err | |
| 127 | + | } | |
| 128 | + | switch provider { | |
| 129 | + | case "anthropic": | |
| 130 | + | if c.Anthropic.APIKey != "" { | |
| 131 | + | return c.Anthropic.APIKey, "apikey", "", nil | |
| 132 | + | } | |
| 133 | + | if c.Anthropic.OAuth != nil && c.Anthropic.OAuth.AccessToken != "" { | |
| 134 | + | return c.Anthropic.OAuth.AccessToken, "oauth", "", nil | |
| 135 | + | } | |
| 136 | + | case "openai": | |
| 137 | + | if c.OpenAI.APIKey != "" { | |
| 138 | + | return c.OpenAI.APIKey, "apikey", "", nil | |
| 139 | + | } | |
| 140 | + | if c.OpenAI.OAuth != nil && c.OpenAI.OAuth.AccessToken != "" { | |
| 141 | + | return c.OpenAI.OAuth.AccessToken, "oauth", c.OpenAI.OAuth.AccountID, nil | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | return "", "", "", fmt.Errorf("no credential for %s", provider) | |
| 145 | + | } | |
A internal/agent/modelsync.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ModelCachePath returns the on-disk location of the merged model cache. | |
| 12 | + | func ModelCachePath() string { | |
| 13 | + | return filepath.Join(ZotHome(), "models-cache.json") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // LoadCachedModels loads the cache file and applies it to the provider | |
| 17 | + | // package so FindModel / ModelsForProvider see live ids immediately. | |
| 18 | + | // Safe to call before any credentials are known. | |
| 19 | + | func LoadCachedModels() { | |
| 20 | + | c, err := provider.LoadCache(ModelCachePath()) | |
| 21 | + | if err != nil { | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | if len(c.Models) > 0 { | |
| 25 | + | provider.SetLiveModels(c.Models) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // RefreshModelsAsync kicks a background discovery for every provider | |
| 30 | + | // we have credentials for. Refreshed results are merged into the | |
| 31 | + | // active catalog and persisted to the on-disk cache. | |
| 32 | + | // | |
| 33 | + | // Silent on error: discovery is a nice-to-have. Callers can still use | |
| 34 | + | // the baked-in catalog if this fails. | |
| 35 | + | func RefreshModelsAsync() { | |
| 36 | + | go refreshModels() | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func refreshModels() { | |
| 40 | + | cached, _ := provider.LoadCache(ModelCachePath()) | |
| 41 | + | if cached.IsFresh() { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | |
| 46 | + | defer cancel() | |
| 47 | + | ||
| 48 | + | var all []provider.Model | |
| 49 | + | ||
| 50 | + | if cred, method, err := ResolveCredential("anthropic", ""); err == nil && method == "apikey" { | |
| 51 | + | // /v1/models on Anthropic is API-key only; OAuth tokens can | |
| 52 | + | // also list models via the bearer header, but we skip OAuth | |
| 53 | + | // here to avoid surprise rate-limit hits on subscription keys. | |
| 54 | + | if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil { | |
| 55 | + | all = append(all, live...) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | if cred, method, err := ResolveCredential("openai", ""); err == nil && method == "apikey" { | |
| 59 | + | if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil { | |
| 60 | + | all = append(all, live...) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | if len(all) == 0 { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | provider.SetLiveModels(all) | |
| 68 | + | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 69 | + | FetchedAt: time.Now().UTC(), | |
| 70 | + | Models: all, | |
| 71 | + | }) | |
| 72 | + | } | |
A internal/agent/modes/dialog_frame.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // frameHeader returns a full-width rule with a small title at the left. | |
| 10 | + | // Matches the thin rule style used for code blocks and tool results so | |
| 11 | + | // every dialog in the TUI looks the same. | |
| 12 | + | // | |
| 13 | + | // ─── title ──────────────────────────────── | |
| 14 | + | func frameHeader(th tui.Theme, title string, width int) string { | |
| 15 | + | label := "── " + title + " " | |
| 16 | + | if width <= 0 { | |
| 17 | + | return th.FG256(th.Muted, label) | |
| 18 | + | } | |
| 19 | + | padLen := width - len(label) | |
| 20 | + | if padLen < 0 { | |
| 21 | + | padLen = 0 | |
| 22 | + | } | |
| 23 | + | return th.FG256(th.Muted, label+strings.Repeat("─", padLen)) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // frameRule returns a full-width horizontal rule in the muted color. | |
| 27 | + | func frameRule(th tui.Theme, width int) string { | |
| 28 | + | if width <= 0 { | |
| 29 | + | width = 1 | |
| 30 | + | } | |
| 31 | + | return th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 32 | + | } | |
A internal/agent/modes/help.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // helpKeyRows is the list of keybindings shown by /help. | |
| 11 | + | var helpKeyRows = [][2]string{ | |
| 12 | + | {"enter", "submit the current input"}, | |
| 13 | + | {"alt+enter", "insert a newline"}, | |
| 14 | + | {"tab", "complete the highlighted slash command"}, | |
| 15 | + | {"esc", "cancel the current turn (while busy) · clear the input (while idle)"}, | |
| 16 | + | {"ctrl+c", "exit (while idle) · cancel the current turn (while busy)"}, | |
| 17 | + | {"ctrl+w", "delete previous word"}, | |
| 18 | + | {"alt+backspace", "delete previous word (same as ctrl+w)"}, | |
| 19 | + | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | |
| 20 | + | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | |
| 21 | + | {"alt+← / alt+→", "jump one word back / forward"}, | |
| 22 | + | {"ctrl+l", "redraw the screen"}, | |
| 23 | + | {"pgup / pgdn", "scroll the chat one page up / down"}, | |
| 24 | + | {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"}, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // renderHelpBlock builds the friendly /help view. Uses the shared | |
| 28 | + | // frameHeader/frameRule helpers so the rules match every other block | |
| 29 | + | // in the tui (tool results, code fences, dialogs) — full terminal width | |
| 30 | + | // in the muted colour. | |
| 31 | + | func renderHelpBlock(th tui.Theme, width int) []string { | |
| 32 | + | if width < 20 { | |
| 33 | + | width = 20 | |
| 34 | + | } | |
| 35 | + | var out []string | |
| 36 | + | out = append(out, frameHeader(th, "zot help", width)) | |
| 37 | + | ||
| 38 | + | // commands section | |
| 39 | + | out = append(out, tui.Bold("slash commands:")) | |
| 40 | + | for _, c := range slashCatalog { | |
| 41 | + | name := c.Name | |
| 42 | + | if len(name) < 10 { | |
| 43 | + | name = name + strings.Repeat(" ", 10-len(name)) | |
| 44 | + | } | |
| 45 | + | out = append(out, fmt.Sprintf(" %s %s", | |
| 46 | + | th.FG256(th.Accent, name), | |
| 47 | + | th.FG256(th.Muted, c.Desc))) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // keys section | |
| 51 | + | out = append(out, "", tui.Bold("keys:")) | |
| 52 | + | for _, k := range helpKeyRows { | |
| 53 | + | name := k[0] | |
| 54 | + | if len(name) < 14 { | |
| 55 | + | name = name + strings.Repeat(" ", 14-len(name)) | |
| 56 | + | } | |
| 57 | + | out = append(out, fmt.Sprintf(" %s %s", | |
| 58 | + | th.FG256(th.Accent, name), | |
| 59 | + | th.FG256(th.Muted, k[1]))) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | out = append(out, frameRule(th, width), "") | |
| 63 | + | return out | |
| 64 | + | } | |
A internal/agent/modes/interactive.go +988 -0
| @@ -0,0 +1,988 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 12 | + | "github.com/patriceckhart/zot/internal/auth" | |
| 13 | + | "github.com/patriceckhart/zot/internal/core" | |
| 14 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 15 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // InteractiveConfig configures the interactive loop. | |
| 19 | + | type InteractiveConfig struct { | |
| 20 | + | Terminal tui.Terminal | |
| 21 | + | Theme tui.Theme | |
| 22 | + | Model string | |
| 23 | + | Provider string | |
| 24 | + | BaseURL string | |
| 25 | + | Reasoning string | |
| 26 | + | SystemPrompt string | |
| 27 | + | Tools core.Registry | |
| 28 | + | MaxSteps int | |
| 29 | + | CWD string | |
| 30 | + | ||
| 31 | + | // Agent is optional. If nil, zot opens without credentials; the | |
| 32 | + | // user must /login before they can prompt. | |
| 33 | + | Agent *core.Agent | |
| 34 | + | ||
| 35 | + | InitialInput string | |
| 36 | + | ||
| 37 | + | // Auth is required. When the user runs /login, Interactive talks to | |
| 38 | + | // AuthManager to open a browser and wait for the callback. | |
| 39 | + | AuthManager *auth.Manager | |
| 40 | + | // BuildAgent is called after a successful login to (re)construct the | |
| 41 | + | // agent with the fresh credential. It returns the new agent and | |
| 42 | + | // the concrete provider/model in use. | |
| 43 | + | BuildAgent func() (*core.Agent, string, string, error) | |
| 44 | + | ||
| 45 | + | // BuildAgentFor rebuilds the agent with an explicit provider/model | |
| 46 | + | // override (used by the /model picker when switching providers). | |
| 47 | + | // If providerOverride is empty, the current provider is kept. | |
| 48 | + | BuildAgentFor func(providerOverride, modelOverride string) (*core.Agent, string, string, error) | |
| 49 | + | ||
| 50 | + | // ZotHome is the root directory for sessions/, used by /sessions. | |
| 51 | + | ZotHome string | |
| 52 | + | ||
| 53 | + | // Sandbox is the shared sandbox pointer. Toggled by /lock and /unlock. | |
| 54 | + | Sandbox *tools.Sandbox | |
| 55 | + | ||
| 56 | + | // LoadSession swaps the current session for the one at path. The | |
| 57 | + | // callback returns the new agent message slice so the TUI can invalidate. | |
| 58 | + | LoadSession func(path string) error | |
| 59 | + | ||
| 60 | + | // PersistModel is called whenever the user switches model or provider. | |
| 61 | + | // It should update config.json and (if there's an active session) | |
| 62 | + | // write a new meta row so resume picks up the same model. | |
| 63 | + | PersistModel func(providerName, model string) | |
| 64 | + | ||
| 65 | + | OnAssistant func(m provider.Message) | |
| 66 | + | OnToolResult func(id string, r core.ToolResult) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Interactive is the TUI chat loop. | |
| 70 | + | type Interactive struct { | |
| 71 | + | cfg InteractiveConfig | |
| 72 | + | view *tui.View | |
| 73 | + | ed *tui.Editor | |
| 74 | + | rend *tui.Renderer | |
| 75 | + | ||
| 76 | + | mu sync.Mutex | |
| 77 | + | agent *core.Agent | |
| 78 | + | streaming strings.Builder | |
| 79 | + | streamOn bool | |
| 80 | + | toolCalls map[string]*tui.ToolCallView | |
| 81 | + | toolOrder []string | |
| 82 | + | statusErr string | |
| 83 | + | statusOK string | |
| 84 | + | helpBlock []string // rendered above the chat when /help was typed | |
| 85 | + | cumUsage provider.Usage | |
| 86 | + | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 87 | + | busy bool | |
| 88 | + | dirty chan struct{} | |
| 89 | + | cancelTurn context.CancelFunc | |
| 90 | + | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 91 | + | ||
| 92 | + | dialog *loginDialog | |
| 93 | + | modelDialog *modelDialog | |
| 94 | + | sessionDialog *sessionDialog | |
| 95 | + | suggest *slashSuggester | |
| 96 | + | spin *spinner | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // NewInteractive constructs an Interactive from cfg. | |
| 100 | + | func NewInteractive(cfg InteractiveConfig) *Interactive { | |
| 101 | + | i := &Interactive{ | |
| 102 | + | cfg: cfg, | |
| 103 | + | view: &tui.View{ | |
| 104 | + | Theme: cfg.Theme, | |
| 105 | + | ImageProto: tui.DetectImageProtocol(), | |
| 106 | + | }, | |
| 107 | + | ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), | |
| 108 | + | rend: tui.NewRenderer(cfg.Terminal), | |
| 109 | + | toolCalls: map[string]*tui.ToolCallView{}, | |
| 110 | + | dirty: make(chan struct{}, 8), | |
| 111 | + | dialog: newLoginDialog(), | |
| 112 | + | modelDialog: newModelDialog(), | |
| 113 | + | sessionDialog: newSessionDialog(), | |
| 114 | + | suggest: newSlashSuggester(), | |
| 115 | + | spin: newSpinner(), | |
| 116 | + | } | |
| 117 | + | if cfg.Agent != nil { | |
| 118 | + | i.agent = cfg.Agent | |
| 119 | + | i.view.Messages = cfg.Agent.Messages() | |
| 120 | + | } | |
| 121 | + | return i | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Run blocks until the user quits. | |
| 125 | + | func (i *Interactive) Run(ctx context.Context) error { | |
| 126 | + | term := i.cfg.Terminal | |
| 127 | + | restore, err := term.EnterRaw() | |
| 128 | + | if err != nil { | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | defer restore() | |
| 132 | + | ||
| 133 | + | _, _ = term.Write([]byte(tui.SeqBracketedPasteOn)) | |
| 134 | + | _, _ = term.Write([]byte(tui.SeqAltScreenOn)) | |
| 135 | + | defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) | |
| 136 | + | ||
| 137 | + | cols, rows := term.Size() | |
| 138 | + | i.rend.Resize(cols, rows) | |
| 139 | + | term.OnResize(func() { | |
| 140 | + | c, r := term.Size() | |
| 141 | + | i.rend.Resize(c, r) | |
| 142 | + | i.invalidate() | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | if i.cfg.InitialInput != "" { | |
| 146 | + | i.ed.SetValue(i.cfg.InitialInput) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // No credential at startup? Auto-open the login dialog, and mark | |
| 150 | + | // the status line. The user can Esc out of the dialog if they | |
| 151 | + | // want to dismiss it (e.g. to check /help or /exit first). | |
| 152 | + | if i.agent == nil { | |
| 153 | + | i.statusErr = "not logged in. pick a login method below or press esc to dismiss." | |
| 154 | + | i.dialog.Open() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Input goroutine. | |
| 158 | + | keys := make(chan tui.Key, 8) | |
| 159 | + | go func() { | |
| 160 | + | reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout) | |
| 161 | + | for { | |
| 162 | + | k, err := reader.Read() | |
| 163 | + | if err != nil { | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | keys <- k | |
| 167 | + | } | |
| 168 | + | }() | |
| 169 | + | ||
| 170 | + | // Subscribe to auth events. | |
| 171 | + | var authEvents <-chan auth.Event | |
| 172 | + | if i.cfg.AuthManager != nil { | |
| 173 | + | authEvents = i.cfg.AuthManager.Events() | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | tick := time.NewTicker(120 * time.Millisecond) | |
| 177 | + | defer tick.Stop() | |
| 178 | + | ||
| 179 | + | i.invalidate() | |
| 180 | + | ||
| 181 | + | for { | |
| 182 | + | select { | |
| 183 | + | case <-ctx.Done(): | |
| 184 | + | return ctx.Err() | |
| 185 | + | case k := <-keys: | |
| 186 | + | if done := i.handleKey(ctx, k); done { | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | i.invalidate() | |
| 190 | + | case ev := <-authEvents: | |
| 191 | + | i.handleAuthEvent(ev) | |
| 192 | + | i.invalidate() | |
| 193 | + | case <-i.dirty: | |
| 194 | + | i.redraw() | |
| 195 | + | case <-tick.C: | |
| 196 | + | if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() { | |
| 197 | + | i.redraw() | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (i *Interactive) invalidate() { | |
| 204 | + | select { | |
| 205 | + | case i.dirty <- struct{}{}: | |
| 206 | + | default: | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // lastCols returns the current terminal width in columns. | |
| 211 | + | func (i *Interactive) lastCols() int { | |
| 212 | + | cols, _ := i.cfg.Terminal.Size() | |
| 213 | + | return cols | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // chatPage returns the number of chat rows currently visible, used | |
| 217 | + | // as the page size for PageUp/PageDown. | |
| 218 | + | func (i *Interactive) chatPage() int { | |
| 219 | + | _, rows := i.cfg.Terminal.Size() | |
| 220 | + | p := rows - 6 // rough reservation for status + editor + a dialog line | |
| 221 | + | if p < 4 { | |
| 222 | + | p = 4 | |
| 223 | + | } | |
| 224 | + | return p | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // scrollBy adjusts the scroll offset. Positive = up (into history). | |
| 228 | + | func (i *Interactive) scrollBy(delta int) { | |
| 229 | + | i.mu.Lock() | |
| 230 | + | i.scrollOffset += delta | |
| 231 | + | if i.scrollOffset < 0 { | |
| 232 | + | i.scrollOffset = 0 | |
| 233 | + | } | |
| 234 | + | i.mu.Unlock() | |
| 235 | + | i.invalidate() | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // scrollToBottom pins the view to the latest content. | |
| 239 | + | func (i *Interactive) scrollToBottom() { | |
| 240 | + | i.mu.Lock() | |
| 241 | + | i.scrollOffset = 0 | |
| 242 | + | i.mu.Unlock() | |
| 243 | + | i.invalidate() | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func (i *Interactive) redraw() { | |
| 247 | + | i.mu.Lock() | |
| 248 | + | defer i.mu.Unlock() | |
| 249 | + | ||
| 250 | + | cols, _ := i.cfg.Terminal.Size() | |
| 251 | + | if i.agent != nil { | |
| 252 | + | i.view.Messages = i.agent.Messages() | |
| 253 | + | } else { | |
| 254 | + | i.view.Messages = nil | |
| 255 | + | } | |
| 256 | + | i.view.Streaming = i.streaming.String() | |
| 257 | + | i.view.StreamingActive = i.streamOn | |
| 258 | + | // Belt and suspenders: if the transcript's last message is already an | |
| 259 | + | // assistant message, the streaming view is stale (EvAssistantMessage may | |
| 260 | + | // not have fired yet) — suppress it to avoid double-rendering the reply. | |
| 261 | + | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 262 | + | i.view.StreamingActive = false | |
| 263 | + | } | |
| 264 | + | i.view.ToolCalls = make([]tui.ToolCallView, 0, len(i.toolOrder)) | |
| 265 | + | for _, id := range i.toolOrder { | |
| 266 | + | if tc, ok := i.toolCalls[id]; ok { | |
| 267 | + | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | i.view.Err = i.statusErr | |
| 271 | + | ||
| 272 | + | chat := i.view.Build(cols) | |
| 273 | + | ||
| 274 | + | // Welcome banner: shown at the top of the chat area when there is | |
| 275 | + | // no transcript yet. Disappears after the first message is sent. | |
| 276 | + | if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 { | |
| 277 | + | chat = append(welcomeBanner(i.cfg.Theme), chat...) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // /help block: rendered above the welcome / transcript when active. | |
| 281 | + | if len(i.helpBlock) > 0 { | |
| 282 | + | chat = append(append([]string(nil), i.helpBlock...), chat...) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | if i.statusOK != "" { | |
| 286 | + | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, "✓ "+i.statusOK), "") | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Dialogs (login or model picker) render between chat and the editor. | |
| 290 | + | var dialog []string | |
| 291 | + | switch { | |
| 292 | + | case i.dialog.Active(): | |
| 293 | + | dialog = i.dialog.Render(i.cfg.Theme, cols) | |
| 294 | + | case i.modelDialog.Active(): | |
| 295 | + | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | |
| 296 | + | case i.sessionDialog.Active(): | |
| 297 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // Slash-command autocomplete: popup above the status line, only | |
| 301 | + | // when the editor starts with "/" and no dialog is already open. | |
| 302 | + | var suggest []string | |
| 303 | + | currentInput := i.ed.Value() | |
| 304 | + | if len(dialog) == 0 && i.suggest.Active(currentInput) && !i.busy { | |
| 305 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // Busy prefix shown at the far left of the status bar. | |
| 309 | + | var busyPrefix string | |
| 310 | + | if i.busy { | |
| 311 | + | busyPrefix = fmt.Sprintf("%s %s · %s", | |
| 312 | + | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Frame()), | |
| 313 | + | i.cfg.Theme.FG256(i.cfg.Theme.Spinner, i.spin.Message()), | |
| 314 | + | i.cfg.Theme.FG256(i.cfg.Theme.Muted, i.spin.Elapsed().String()), | |
| 315 | + | ) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | ctxMax := 0 | |
| 319 | + | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 320 | + | ctxMax = m.ContextWindow | |
| 321 | + | } | |
| 322 | + | status := tui.StatusBar(i.cfg.Theme, i.cfg.Model, i.cfg.Provider, i.cumUsage, i.busy, busyPrefix, i.cfg.CWD, i.cfg.Sandbox.Locked(), i.lastCtxInput, ctxMax, cols) | |
| 323 | + | edLines, curR, curC := i.ed.Render(cols) | |
| 324 | + | ||
| 325 | + | // Bottom-sticky sections (always visible, never scroll). | |
| 326 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1) | |
| 327 | + | bottom = append(bottom, dialog...) | |
| 328 | + | bottom = append(bottom, suggest...) | |
| 329 | + | bottom = append(bottom, status) | |
| 330 | + | bottom = append(bottom, edLines...) | |
| 331 | + | ||
| 332 | + | _, rows := i.cfg.Terminal.Size() | |
| 333 | + | chatRows := rows - len(bottom) | |
| 334 | + | if chatRows < 1 { | |
| 335 | + | chatRows = 1 | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Apply scroll offset to the chat slice. | |
| 339 | + | maxOffset := len(chat) - chatRows | |
| 340 | + | if maxOffset < 0 { | |
| 341 | + | maxOffset = 0 | |
| 342 | + | } | |
| 343 | + | if i.scrollOffset > maxOffset { | |
| 344 | + | i.scrollOffset = maxOffset | |
| 345 | + | } | |
| 346 | + | if i.scrollOffset < 0 { | |
| 347 | + | i.scrollOffset = 0 | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | var visibleChat []string | |
| 351 | + | if len(chat) <= chatRows { | |
| 352 | + | visibleChat = chat | |
| 353 | + | } else { | |
| 354 | + | end := len(chat) - i.scrollOffset | |
| 355 | + | start := end - chatRows | |
| 356 | + | if start < 0 { | |
| 357 | + | start = 0 | |
| 358 | + | } | |
| 359 | + | visibleChat = chat[start:end] | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // A tiny "scrolled up" indicator in the top-right of the chat pane | |
| 363 | + | // so you know you're not at the bottom. | |
| 364 | + | if i.scrollOffset > 0 && len(visibleChat) > 0 { | |
| 365 | + | note := i.cfg.Theme.FG256(i.cfg.Theme.Muted, | |
| 366 | + | fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)) | |
| 367 | + | visibleChat = append([]string{note}, visibleChat...) | |
| 368 | + | if len(visibleChat) > chatRows { | |
| 369 | + | visibleChat = visibleChat[:chatRows] | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | frame := make([]string, 0, len(visibleChat)+len(bottom)) | |
| 374 | + | frame = append(frame, visibleChat...) | |
| 375 | + | frame = append(frame, bottom...) | |
| 376 | + | ||
| 377 | + | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR | |
| 378 | + | cursorCol := curC | |
| 379 | + | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 383 | + | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 384 | + | if i.dialog.Active() { | |
| 385 | + | if k.Kind == tui.KeyCtrlC { | |
| 386 | + | i.dialog.Close() | |
| 387 | + | if i.cfg.AuthManager != nil { | |
| 388 | + | i.cfg.AuthManager.CancelOAuth() | |
| 389 | + | } | |
| 390 | + | return false | |
| 391 | + | } | |
| 392 | + | act := i.dialog.HandleKey(k) | |
| 393 | + | if act.StartAPIKey { | |
| 394 | + | i.startAPIKeyFlow(act.Provider) | |
| 395 | + | } | |
| 396 | + | if act.StartOAuth { | |
| 397 | + | i.startOAuthFlow(act.Provider) | |
| 398 | + | } | |
| 399 | + | return false | |
| 400 | + | } | |
| 401 | + | if i.modelDialog.Active() { | |
| 402 | + | if k.Kind == tui.KeyCtrlC { | |
| 403 | + | i.modelDialog.Close() | |
| 404 | + | return false | |
| 405 | + | } | |
| 406 | + | act := i.modelDialog.HandleKey(k) | |
| 407 | + | if act.Select { | |
| 408 | + | i.applyModelSelection(act.Provider, act.Model) | |
| 409 | + | } | |
| 410 | + | return false | |
| 411 | + | } | |
| 412 | + | if i.sessionDialog.Active() { | |
| 413 | + | if k.Kind == tui.KeyCtrlC { | |
| 414 | + | i.sessionDialog.Close() | |
| 415 | + | return false | |
| 416 | + | } | |
| 417 | + | act := i.sessionDialog.HandleKey(k) | |
| 418 | + | if act.Select { | |
| 419 | + | i.applySessionSelection(act.Path) | |
| 420 | + | } | |
| 421 | + | return false | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | // Global keys. | |
| 425 | + | switch k.Kind { | |
| 426 | + | case tui.KeyCtrlC: | |
| 427 | + | if i.busy && i.cancelTurn != nil { | |
| 428 | + | i.cancelTurn() | |
| 429 | + | return false | |
| 430 | + | } | |
| 431 | + | return true | |
| 432 | + | case tui.KeyEsc: | |
| 433 | + | // Esc interrupts a running turn. When idle, fall through so the | |
| 434 | + | // editor can clear itself. | |
| 435 | + | if i.busy && i.cancelTurn != nil { | |
| 436 | + | i.cancelTurn() | |
| 437 | + | return false | |
| 438 | + | } | |
| 439 | + | case tui.KeyCtrlD: | |
| 440 | + | if i.ed.IsEmpty() && !i.busy { | |
| 441 | + | return true | |
| 442 | + | } | |
| 443 | + | case tui.KeyCtrlL: | |
| 444 | + | i.rend.Clear() | |
| 445 | + | i.invalidate() | |
| 446 | + | return false | |
| 447 | + | case tui.KeyPageUp: | |
| 448 | + | i.scrollBy(+i.chatPage()) | |
| 449 | + | return false | |
| 450 | + | case tui.KeyPageDown: | |
| 451 | + | i.scrollBy(-i.chatPage()) | |
| 452 | + | return false | |
| 453 | + | case tui.KeyUp: | |
| 454 | + | // Wheel-up in alt screen sends Up arrows on most terminals. When the | |
| 455 | + | // editor is empty and we're not busy, use that for chat scroll-up. | |
| 456 | + | if i.ed.IsEmpty() && !i.busy { | |
| 457 | + | i.scrollBy(+3) | |
| 458 | + | return false | |
| 459 | + | } | |
| 460 | + | case tui.KeyDown: | |
| 461 | + | if i.ed.IsEmpty() && !i.busy { | |
| 462 | + | if i.scrollOffset > 0 { | |
| 463 | + | i.scrollBy(-3) | |
| 464 | + | } | |
| 465 | + | return false | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | if i.busy { | |
| 470 | + | return false | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | if k.Kind == tui.KeyEnter && k.Alt { | |
| 474 | + | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | |
| 475 | + | return false | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | // Slash suggestions: intercept up/down/tab/enter when the popup is visible. | |
| 479 | + | if i.suggest.Active(i.ed.Value()) { | |
| 480 | + | switch k.Kind { | |
| 481 | + | case tui.KeyUp: | |
| 482 | + | i.suggest.Up() | |
| 483 | + | return false | |
| 484 | + | case tui.KeyDown: | |
| 485 | + | i.suggest.Down() | |
| 486 | + | return false | |
| 487 | + | case tui.KeyTab: | |
| 488 | + | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 489 | + | i.ed.SetValue(name) | |
| 490 | + | i.suggest.Reset() | |
| 491 | + | } | |
| 492 | + | return false | |
| 493 | + | case tui.KeyEnter: | |
| 494 | + | // Enter on an ambiguous or partial slash prefix: complete to the | |
| 495 | + | // currently highlighted command and run it. That way typing | |
| 496 | + | // "/lo" + enter picks whichever of /login or /logout is selected | |
| 497 | + | // in the popup instead of submitting "/lo" as unknown. Also | |
| 498 | + | // clear the editor so the command doesn't linger after the | |
| 499 | + | // dialog opens/closes. | |
| 500 | + | if name := i.suggest.Selection(i.ed.Value()); name != "" { | |
| 501 | + | i.ed.PushHistory(name) | |
| 502 | + | i.ed.Clear() | |
| 503 | + | i.suggest.Reset() | |
| 504 | + | return i.runSlash(ctx, name) | |
| 505 | + | } | |
| 506 | + | case tui.KeyEsc: | |
| 507 | + | i.ed.Clear() | |
| 508 | + | i.suggest.Reset() | |
| 509 | + | return false | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | if submit := i.ed.HandleKey(k); submit { | |
| 514 | + | text := strings.TrimRight(i.ed.Value(), "\n") | |
| 515 | + | if text == "" { | |
| 516 | + | return false | |
| 517 | + | } | |
| 518 | + | i.ed.PushHistory(text) | |
| 519 | + | i.ed.Clear() | |
| 520 | + | i.suggest.Reset() | |
| 521 | + | ||
| 522 | + | if looksLikeSlashCommand(text) { | |
| 523 | + | if !isKnownSlashCommand(text) { | |
| 524 | + | head := text | |
| 525 | + | if idx := strings.IndexAny(text, " \t"); idx >= 0 { | |
| 526 | + | head = text[:idx] | |
| 527 | + | } | |
| 528 | + | i.mu.Lock() | |
| 529 | + | i.statusErr = "unknown command " + head + " — type /help to see the list" | |
| 530 | + | i.statusOK = "" | |
| 531 | + | i.mu.Unlock() | |
| 532 | + | return false | |
| 533 | + | } | |
| 534 | + | return i.runSlash(ctx, text) | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | if i.agent == nil { | |
| 538 | + | i.mu.Lock() | |
| 539 | + | i.statusErr = "not logged in. type /login first." | |
| 540 | + | i.mu.Unlock() | |
| 541 | + | return false | |
| 542 | + | } | |
| 543 | + | i.startTurn(ctx, text) | |
| 544 | + | } | |
| 545 | + | return false | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 549 | + | parts := strings.Fields(cmd) | |
| 550 | + | switch parts[0] { | |
| 551 | + | case "/exit": | |
| 552 | + | return true | |
| 553 | + | case "/clear": | |
| 554 | + | if i.agent != nil { | |
| 555 | + | i.agent.SetMessages(nil) | |
| 556 | + | } | |
| 557 | + | i.mu.Lock() | |
| 558 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 559 | + | i.toolOrder = nil | |
| 560 | + | i.statusErr = "" | |
| 561 | + | i.statusOK = "" | |
| 562 | + | i.helpBlock = nil | |
| 563 | + | i.mu.Unlock() | |
| 564 | + | case "/help": | |
| 565 | + | i.mu.Lock() | |
| 566 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols()) | |
| 567 | + | i.statusErr = "" | |
| 568 | + | i.statusOK = "" | |
| 569 | + | i.mu.Unlock() | |
| 570 | + | case "/login": | |
| 571 | + | i.dialog.Open() | |
| 572 | + | case "/logout": | |
| 573 | + | target := "all" | |
| 574 | + | if len(parts) >= 2 { | |
| 575 | + | target = parts[1] | |
| 576 | + | } | |
| 577 | + | i.doLogout(target) | |
| 578 | + | case "/model": | |
| 579 | + | if len(parts) >= 2 { | |
| 580 | + | i.applyModelSelection("", parts[1]) | |
| 581 | + | } else { | |
| 582 | + | i.modelDialog.Open(i.cfg.Model) | |
| 583 | + | } | |
| 584 | + | case "/sessions": | |
| 585 | + | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 586 | + | case "/compact": | |
| 587 | + | i.runCompact(ctx) | |
| 588 | + | case "/lock": | |
| 589 | + | if i.cfg.Sandbox == nil { | |
| 590 | + | i.mu.Lock() | |
| 591 | + | i.statusErr = "sandbox not available in this build" | |
| 592 | + | i.mu.Unlock() | |
| 593 | + | break | |
| 594 | + | } | |
| 595 | + | i.cfg.Sandbox.Lock() | |
| 596 | + | i.mu.Lock() | |
| 597 | + | i.statusOK = "locked to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)" | |
| 598 | + | i.statusErr = "" | |
| 599 | + | i.mu.Unlock() | |
| 600 | + | case "/unlock": | |
| 601 | + | if i.cfg.Sandbox == nil { | |
| 602 | + | i.mu.Lock() | |
| 603 | + | i.statusErr = "sandbox not available in this build" | |
| 604 | + | i.mu.Unlock() | |
| 605 | + | break | |
| 606 | + | } | |
| 607 | + | i.cfg.Sandbox.Unlock() | |
| 608 | + | i.mu.Lock() | |
| 609 | + | i.statusOK = "unlocked" | |
| 610 | + | i.statusErr = "" | |
| 611 | + | i.mu.Unlock() | |
| 612 | + | default: | |
| 613 | + | i.mu.Lock() | |
| 614 | + | i.statusErr = "unknown command: " + parts[0] | |
| 615 | + | i.mu.Unlock() | |
| 616 | + | } | |
| 617 | + | return false | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // doLogout clears credentials for the given provider (or all providers) | |
| 621 | + | // from auth.json. If the active agent was using those credentials, it | |
| 622 | + | // is torn down so the user is forced through /login before their next | |
| 623 | + | // prompt. | |
| 624 | + | // | |
| 625 | + | // target: "anthropic" | "openai" | "all" | |
| 626 | + | func (i *Interactive) doLogout(target string) { | |
| 627 | + | if i.cfg.AuthManager == nil { | |
| 628 | + | i.mu.Lock() | |
| 629 | + | i.statusErr = "no auth manager configured" | |
| 630 | + | i.mu.Unlock() | |
| 631 | + | return | |
| 632 | + | } | |
| 633 | + | store := i.cfg.AuthManager.Store() | |
| 634 | + | if store == nil { | |
| 635 | + | i.mu.Lock() | |
| 636 | + | i.statusErr = "auth store is not available" | |
| 637 | + | i.mu.Unlock() | |
| 638 | + | return | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | var providers []string | |
| 642 | + | switch target { | |
| 643 | + | case "", "all": | |
| 644 | + | providers = []string{"anthropic", "openai"} | |
| 645 | + | case "anthropic", "openai": | |
| 646 | + | providers = []string{target} | |
| 647 | + | default: | |
| 648 | + | i.mu.Lock() | |
| 649 | + | i.statusErr = "unknown provider: " + target + " (use anthropic, openai, or all)" | |
| 650 | + | i.mu.Unlock() | |
| 651 | + | return | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | var errs []string | |
| 655 | + | clearedCurrent := false | |
| 656 | + | for _, p := range providers { | |
| 657 | + | if err := store.Clear(p); err != nil { | |
| 658 | + | errs = append(errs, p+": "+err.Error()) | |
| 659 | + | continue | |
| 660 | + | } | |
| 661 | + | if p == i.cfg.Provider { | |
| 662 | + | clearedCurrent = true | |
| 663 | + | } | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | i.mu.Lock() | |
| 667 | + | defer i.mu.Unlock() | |
| 668 | + | if len(errs) > 0 { | |
| 669 | + | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | |
| 670 | + | return | |
| 671 | + | } | |
| 672 | + | i.statusErr = "" | |
| 673 | + | if clearedCurrent { | |
| 674 | + | // The running agent was using a credential we just wiped. Drop | |
| 675 | + | // it so prompts can't go out with the stale client, and hint at | |
| 676 | + | // /login. | |
| 677 | + | i.agent = nil | |
| 678 | + | i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in." | |
| 679 | + | } else { | |
| 680 | + | i.statusOK = "logged out of " + strings.Join(providers, ", ") | |
| 681 | + | } | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | func (i *Interactive) startAPIKeyFlow(provider string) { | |
| 685 | + | url, err := i.cfg.AuthManager.StartAPIKey(provider) | |
| 686 | + | if err != nil { | |
| 687 | + | i.dialog.ShowResult(false, err.Error()) | |
| 688 | + | return | |
| 689 | + | } | |
| 690 | + | i.dialog.ShowWaiting(url) | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func (i *Interactive) startOAuthFlow(provider string) { | |
| 694 | + | url, err := i.cfg.AuthManager.StartOAuth(provider) | |
| 695 | + | if err != nil { | |
| 696 | + | i.dialog.ShowResult(false, err.Error()) | |
| 697 | + | return | |
| 698 | + | } | |
| 699 | + | i.dialog.ShowWaiting(url) | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | // applyModelSelection switches the active model (and provider, if the | |
| 703 | + | // new model belongs to a different one). It rebuilds the underlying | |
| 704 | + | // client when needed so the provider wire-protocol matches. | |
| 705 | + | // applySessionSelection loads the given session via the cli-provided callback. | |
| 706 | + | func (i *Interactive) applySessionSelection(path string) { | |
| 707 | + | if i.cfg.LoadSession == nil { | |
| 708 | + | i.mu.Lock() | |
| 709 | + | i.statusErr = "session loading is not wired in this build" | |
| 710 | + | i.mu.Unlock() | |
| 711 | + | return | |
| 712 | + | } | |
| 713 | + | if err := i.cfg.LoadSession(path); err != nil { | |
| 714 | + | i.mu.Lock() | |
| 715 | + | i.statusErr = err.Error() | |
| 716 | + | i.mu.Unlock() | |
| 717 | + | return | |
| 718 | + | } | |
| 719 | + | i.mu.Lock() | |
| 720 | + | i.statusOK = "resumed session: " + path | |
| 721 | + | i.statusErr = "" | |
| 722 | + | i.scrollOffset = 0 | |
| 723 | + | i.mu.Unlock() | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | func (i *Interactive) applyModelSelection(prov, model string) { | |
| 727 | + | if model == "" { | |
| 728 | + | return | |
| 729 | + | } | |
| 730 | + | m, err := provider.FindModel(prov, model) | |
| 731 | + | if err != nil { | |
| 732 | + | i.mu.Lock() | |
| 733 | + | i.statusErr = err.Error() | |
| 734 | + | i.mu.Unlock() | |
| 735 | + | return | |
| 736 | + | } | |
| 737 | + | // Same provider? Just swap the model on the existing agent. | |
| 738 | + | if i.agent != nil && m.Provider == i.cfg.Provider { | |
| 739 | + | i.mu.Lock() | |
| 740 | + | i.cfg.Model = m.ID | |
| 741 | + | i.agent.Model = m.ID | |
| 742 | + | i.statusOK = "model: " + m.ID | |
| 743 | + | i.statusErr = "" | |
| 744 | + | i.mu.Unlock() | |
| 745 | + | if i.cfg.PersistModel != nil { | |
| 746 | + | i.cfg.PersistModel(i.cfg.Provider, m.ID) | |
| 747 | + | } | |
| 748 | + | return | |
| 749 | + | } | |
| 750 | + | // Different provider: rebuild agent (needs credentials for target provider). | |
| 751 | + | if i.cfg.BuildAgentFor == nil { | |
| 752 | + | i.mu.Lock() | |
| 753 | + | i.statusErr = "cannot switch provider: no builder configured" | |
| 754 | + | i.mu.Unlock() | |
| 755 | + | return | |
| 756 | + | } | |
| 757 | + | ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID) | |
| 758 | + | if err != nil { | |
| 759 | + | i.mu.Lock() | |
| 760 | + | i.statusErr = err.Error() | |
| 761 | + | i.mu.Unlock() | |
| 762 | + | return | |
| 763 | + | } | |
| 764 | + | i.mu.Lock() | |
| 765 | + | i.agent = ag | |
| 766 | + | i.cfg.Provider = p | |
| 767 | + | i.cfg.Model = md | |
| 768 | + | i.statusOK = "switched to " + p + " / " + md | |
| 769 | + | i.statusErr = "" | |
| 770 | + | i.mu.Unlock() | |
| 771 | + | if i.cfg.PersistModel != nil { | |
| 772 | + | i.cfg.PersistModel(p, md) | |
| 773 | + | } | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 777 | + | switch ev.Kind { | |
| 778 | + | case "started": | |
| 779 | + | i.dialog.ShowWaiting(ev.URL) | |
| 780 | + | case "browser_open": | |
| 781 | + | // no-op | |
| 782 | + | case "error": | |
| 783 | + | i.dialog.ShowResult(false, ev.Message) | |
| 784 | + | case "success": | |
| 785 | + | // Rebuild the agent with the fresh credential. | |
| 786 | + | ag, prov, model, err := i.cfg.BuildAgent() | |
| 787 | + | if err != nil { | |
| 788 | + | i.dialog.ShowResult(false, err.Error()) | |
| 789 | + | return | |
| 790 | + | } | |
| 791 | + | i.mu.Lock() | |
| 792 | + | i.agent = ag | |
| 793 | + | i.cfg.Provider = prov | |
| 794 | + | i.cfg.Model = model | |
| 795 | + | i.statusErr = "" | |
| 796 | + | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 797 | + | i.mu.Unlock() | |
| 798 | + | i.dialog.ShowResult(true, "") | |
| 799 | + | } | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | // runCompact invokes core.Agent.Compact and reflects the progress in | |
| 803 | + | // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c | |
| 804 | + | // cancel via the same cancelTurn channel used for normal turns. | |
| 805 | + | func (i *Interactive) runCompact(parent context.Context) { | |
| 806 | + | if i.agent == nil { | |
| 807 | + | i.mu.Lock() | |
| 808 | + | i.statusErr = "not logged in. type /login first." | |
| 809 | + | i.mu.Unlock() | |
| 810 | + | return | |
| 811 | + | } | |
| 812 | + | ctx, cancel := context.WithCancel(parent) | |
| 813 | + | i.mu.Lock() | |
| 814 | + | i.busy = true | |
| 815 | + | i.spin.Start() | |
| 816 | + | i.cancelTurn = cancel | |
| 817 | + | i.statusErr = "" | |
| 818 | + | i.statusOK = "compacting..." | |
| 819 | + | i.streaming.Reset() | |
| 820 | + | i.streamOn = true | |
| 821 | + | i.scrollOffset = 0 | |
| 822 | + | i.helpBlock = nil | |
| 823 | + | i.mu.Unlock() | |
| 824 | + | i.invalidate() | |
| 825 | + | ||
| 826 | + | go func() { | |
| 827 | + | sink := func(delta string) { | |
| 828 | + | i.mu.Lock() | |
| 829 | + | i.streaming.WriteString(delta) | |
| 830 | + | i.mu.Unlock() | |
| 831 | + | i.invalidate() | |
| 832 | + | } | |
| 833 | + | summary, err := i.agent.Compact(ctx, 4, sink) | |
| 834 | + | i.mu.Lock() | |
| 835 | + | i.busy = false | |
| 836 | + | i.streamOn = false | |
| 837 | + | i.streaming.Reset() | |
| 838 | + | i.cancelTurn = nil | |
| 839 | + | switch { | |
| 840 | + | case err != nil && ctx.Err() != nil: | |
| 841 | + | i.statusErr = "" | |
| 842 | + | i.statusOK = "compaction cancelled" | |
| 843 | + | case err != nil: | |
| 844 | + | i.statusErr = "compaction failed: " + err.Error() | |
| 845 | + | i.statusOK = "" | |
| 846 | + | default: | |
| 847 | + | i.statusErr = "" | |
| 848 | + | i.statusOK = fmt.Sprintf("compacted transcript (%d chars of summary)", len(summary)) | |
| 849 | + | i.lastCtxInput = 0 // reset; next turn will get a fresh measurement | |
| 850 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 851 | + | i.toolOrder = nil | |
| 852 | + | } | |
| 853 | + | i.mu.Unlock() | |
| 854 | + | i.invalidate() | |
| 855 | + | }() | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | func (i *Interactive) startTurn(parent context.Context, prompt string) { | |
| 859 | + | if i.agent == nil { | |
| 860 | + | return | |
| 861 | + | } | |
| 862 | + | ctx, cancel := context.WithCancel(parent) | |
| 863 | + | i.mu.Lock() | |
| 864 | + | i.busy = true | |
| 865 | + | i.spin.Start() | |
| 866 | + | i.cancelTurn = cancel | |
| 867 | + | i.statusErr = "" | |
| 868 | + | i.statusOK = "" | |
| 869 | + | i.streaming.Reset() | |
| 870 | + | i.streamOn = true | |
| 871 | + | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 872 | + | i.toolOrder = nil | |
| 873 | + | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 874 | + | i.helpBlock = nil // hide the help block once the user asks something | |
| 875 | + | i.mu.Unlock() | |
| 876 | + | i.invalidate() | |
| 877 | + | ||
| 878 | + | sink := func(ev core.AgentEvent) { | |
| 879 | + | i.handleEvent(ev) | |
| 880 | + | i.invalidate() | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | go func() { | |
| 884 | + | err := i.agent.Prompt(ctx, prompt, nil, sink) | |
| 885 | + | i.mu.Lock() | |
| 886 | + | i.busy = false | |
| 887 | + | i.streamOn = false | |
| 888 | + | i.cancelTurn = nil | |
| 889 | + | if err != nil && ctx.Err() == nil { | |
| 890 | + | i.statusErr = err.Error() | |
| 891 | + | } | |
| 892 | + | i.mu.Unlock() | |
| 893 | + | i.invalidate() | |
| 894 | + | }() | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| 898 | + | i.mu.Lock() | |
| 899 | + | defer i.mu.Unlock() | |
| 900 | + | switch e := ev.(type) { | |
| 901 | + | case core.EvTextDelta: | |
| 902 | + | i.streaming.WriteString(e.Delta) | |
| 903 | + | case core.EvAssistantMessage: | |
| 904 | + | i.streaming.Reset() | |
| 905 | + | i.streamOn = false | |
| 906 | + | if i.cfg.OnAssistant != nil { | |
| 907 | + | i.cfg.OnAssistant(e.Message) | |
| 908 | + | } | |
| 909 | + | case core.EvToolCall: | |
| 910 | + | tcv := &tui.ToolCallView{ | |
| 911 | + | ID: e.ID, | |
| 912 | + | Name: e.Name, | |
| 913 | + | Args: shortArgs(e.Args), | |
| 914 | + | } | |
| 915 | + | i.toolCalls[e.ID] = tcv | |
| 916 | + | i.toolOrder = append(i.toolOrder, e.ID) | |
| 917 | + | case core.EvToolResult: | |
| 918 | + | if tc, ok := i.toolCalls[e.ID]; ok { | |
| 919 | + | tc.Done = true | |
| 920 | + | tc.Error = e.Result.IsError | |
| 921 | + | var text strings.Builder | |
| 922 | + | for _, c := range e.Result.Content { | |
| 923 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 924 | + | if text.Len() > 0 { | |
| 925 | + | text.WriteString("\n") | |
| 926 | + | } | |
| 927 | + | text.WriteString(tb.Text) | |
| 928 | + | } | |
| 929 | + | } | |
| 930 | + | tc.Result = text.String() | |
| 931 | + | } | |
| 932 | + | if i.cfg.OnToolResult != nil { | |
| 933 | + | i.cfg.OnToolResult(e.ID, e.Result) | |
| 934 | + | } | |
| 935 | + | case core.EvUsage: | |
| 936 | + | i.cumUsage = e.Cumulative | |
| 937 | + | if e.Usage.InputTokens > 0 { | |
| 938 | + | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | |
| 939 | + | } | |
| 940 | + | case core.EvTurnEnd: | |
| 941 | + | if e.Stop == provider.StopAborted { | |
| 942 | + | // Aborted turn: discard the partial streaming text (it is not | |
| 943 | + | // persisted in the transcript) and clear any transient error. | |
| 944 | + | i.streaming.Reset() | |
| 945 | + | i.streamOn = false | |
| 946 | + | i.statusErr = "" | |
| 947 | + | i.statusOK = "cancelled" | |
| 948 | + | return | |
| 949 | + | } | |
| 950 | + | if e.Err != nil && !strings.Contains(e.Err.Error(), "context canceled") { | |
| 951 | + | i.statusErr = e.Err.Error() | |
| 952 | + | } | |
| 953 | + | } | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | // Agent returns the current agent, if any. Used by cli.go to flush the | |
| 957 | + | // final transcript to the session file. | |
| 958 | + | func (i *Interactive) Agent() *core.Agent { | |
| 959 | + | i.mu.Lock() | |
| 960 | + | defer i.mu.Unlock() | |
| 961 | + | return i.agent | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | func shortArgs(raw json.RawMessage) string { | |
| 965 | + | var v any | |
| 966 | + | if err := json.Unmarshal(raw, &v); err != nil { | |
| 967 | + | return "" | |
| 968 | + | } | |
| 969 | + | if m, ok := v.(map[string]any); ok { | |
| 970 | + | for _, k := range []string{"path", "file_path", "command"} { | |
| 971 | + | if s, ok := m[k].(string); ok { | |
| 972 | + | if len(s) > 60 { | |
| 973 | + | s = s[:57] + "..." | |
| 974 | + | } | |
| 975 | + | return s | |
| 976 | + | } | |
| 977 | + | } | |
| 978 | + | } | |
| 979 | + | b, _ := json.Marshal(v) | |
| 980 | + | s := string(b) | |
| 981 | + | if len(s) > 60 { | |
| 982 | + | s = s[:57] + "..." | |
| 983 | + | } | |
| 984 | + | return s | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | // silence unused import in some build configs | |
| 988 | + | var _ = fmt.Sprintf | |
A internal/agent/modes/json.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/core" | |
| 10 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // RunJSON runs the agent to completion, writing one JSON object per | |
| 14 | + | // AgentEvent as newline-delimited JSON. | |
| 15 | + | func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | |
| 16 | + | enc := json.NewEncoder(out) | |
| 17 | + | write := func(v any) { | |
| 18 | + | _ = enc.Encode(v) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | var runErr error | |
| 22 | + | sink := func(ev core.AgentEvent) { | |
| 23 | + | write(eventToJSON(ev)) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 27 | + | runErr = err | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | if runErr != nil { | |
| 31 | + | fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`) | |
| 32 | + | } | |
| 33 | + | return runErr | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // eventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire | |
| 37 | + | // schema is deliberately simple and flat. | |
| 38 | + | func eventToJSON(ev core.AgentEvent) map[string]any { | |
| 39 | + | m := map[string]any{"type": ev.Type()} | |
| 40 | + | switch e := ev.(type) { | |
| 41 | + | case core.EvTurnStart: | |
| 42 | + | m["step"] = e.Step | |
| 43 | + | case core.EvUserMessage: | |
| 44 | + | m["content"] = contentToJSON(e.Message.Content) | |
| 45 | + | m["time"] = e.Message.Time | |
| 46 | + | case core.EvAssistantMessage: | |
| 47 | + | m["content"] = contentToJSON(e.Message.Content) | |
| 48 | + | m["time"] = e.Message.Time | |
| 49 | + | case core.EvTextDelta: | |
| 50 | + | m["delta"] = e.Delta | |
| 51 | + | case core.EvToolCall: | |
| 52 | + | m["id"] = e.ID | |
| 53 | + | m["name"] = e.Name | |
| 54 | + | var args any | |
| 55 | + | _ = json.Unmarshal(e.Args, &args) | |
| 56 | + | m["args"] = args | |
| 57 | + | case core.EvToolProgress: | |
| 58 | + | m["id"] = e.ID | |
| 59 | + | m["text"] = e.Text | |
| 60 | + | case core.EvToolResult: | |
| 61 | + | m["id"] = e.ID | |
| 62 | + | m["is_error"] = e.Result.IsError | |
| 63 | + | m["content"] = contentToJSON(e.Result.Content) | |
| 64 | + | case core.EvUsage: | |
| 65 | + | m["input"] = e.Usage.InputTokens | |
| 66 | + | m["output"] = e.Usage.OutputTokens | |
| 67 | + | m["cache_read"] = e.Usage.CacheReadTokens | |
| 68 | + | m["cache_write"] = e.Usage.CacheWriteTokens | |
| 69 | + | m["cost_usd"] = e.Usage.CostUSD | |
| 70 | + | m["cumulative"] = map[string]any{ | |
| 71 | + | "input": e.Cumulative.InputTokens, | |
| 72 | + | "output": e.Cumulative.OutputTokens, | |
| 73 | + | "cache_read": e.Cumulative.CacheReadTokens, | |
| 74 | + | "cache_write": e.Cumulative.CacheWriteTokens, | |
| 75 | + | "cost_usd": e.Cumulative.CostUSD, | |
| 76 | + | } | |
| 77 | + | case core.EvTurnEnd: | |
| 78 | + | m["stop"] = string(e.Stop) | |
| 79 | + | if e.Err != nil { | |
| 80 | + | m["error"] = e.Err.Error() | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return m | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func contentToJSON(blocks []provider.Content) []map[string]any { | |
| 87 | + | out := make([]map[string]any, 0, len(blocks)) | |
| 88 | + | for _, b := range blocks { | |
| 89 | + | switch v := b.(type) { | |
| 90 | + | case provider.TextBlock: | |
| 91 | + | out = append(out, map[string]any{"type": "text", "text": v.Text}) | |
| 92 | + | case provider.ImageBlock: | |
| 93 | + | out = append(out, map[string]any{"type": "image", "mime_type": v.MimeType, "bytes": len(v.Data)}) | |
| 94 | + | case provider.ToolCallBlock: | |
| 95 | + | var args any | |
| 96 | + | _ = json.Unmarshal(v.Arguments, &args) | |
| 97 | + | out = append(out, map[string]any{"type": "tool_call", "id": v.ID, "name": v.Name, "args": args}) | |
| 98 | + | case provider.ToolResultBlock: | |
| 99 | + | out = append(out, map[string]any{ | |
| 100 | + | "type": "tool_result", | |
| 101 | + | "call_id": v.CallID, | |
| 102 | + | "is_error": v.IsError, | |
| 103 | + | "content": contentToJSON(v.Content), | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return out | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func jsonString(s string) string { | |
| 111 | + | b, _ := json.Marshal(s) | |
| 112 | + | return string(b) | |
| 113 | + | } | |
A internal/agent/modes/login_dialog.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // loginStep is the current node in the login dialog state machine. | |
| 10 | + | type loginStep int | |
| 11 | + | ||
| 12 | + | // loginStepClosed is the zero value on purpose: a freshly-constructed | |
| 13 | + | // dialog must default to closed so nothing shows up until Open() is | |
| 14 | + | // explicitly called. | |
| 15 | + | const ( | |
| 16 | + | loginStepClosed loginStep = iota | |
| 17 | + | loginStepMethod // pick apikey vs subscription | |
| 18 | + | loginStepProvider // pick anthropic vs openai | |
| 19 | + | loginStepWaiting // browser open, waiting for callback | |
| 20 | + | loginStepDone // success or error, waiting for key to dismiss | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // loginDialog is a tiny inline dialog rendered above the editor while | |
| 24 | + | // the user picks their login method and provider. | |
| 25 | + | type loginDialog struct { | |
| 26 | + | step loginStep | |
| 27 | + | method string // "apikey" | "oauth" | |
| 28 | + | provider string // "anthropic" | "openai" | |
| 29 | + | message string | |
| 30 | + | success bool | |
| 31 | + | url string | |
| 32 | + | cursor int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func newLoginDialog() *loginDialog { | |
| 36 | + | return &loginDialog{} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Active reports whether the dialog consumes input. | |
| 40 | + | func (d *loginDialog) Active() bool { return d != nil && d.step != loginStepClosed } | |
| 41 | + | ||
| 42 | + | // Open starts the dialog from scratch. | |
| 43 | + | func (d *loginDialog) Open() { | |
| 44 | + | d.step = loginStepMethod | |
| 45 | + | d.method = "" | |
| 46 | + | d.provider = "" | |
| 47 | + | d.message = "" | |
| 48 | + | d.success = false | |
| 49 | + | d.url = "" | |
| 50 | + | d.cursor = 0 | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Close hides the dialog. | |
| 54 | + | func (d *loginDialog) Close() { | |
| 55 | + | d.step = loginStepClosed | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Render returns the dialog lines or nil when inactive. | |
| 59 | + | func (d *loginDialog) Render(th tui.Theme, width int) []string { | |
| 60 | + | if !d.Active() { | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | var lines []string | |
| 64 | + | ||
| 65 | + | switch d.step { | |
| 66 | + | case loginStepMethod: | |
| 67 | + | opts := []string{ | |
| 68 | + | "api key", | |
| 69 | + | "subscription (claude pro/max · chatgpt plus/pro)", | |
| 70 | + | } | |
| 71 | + | lines = append(lines, frameHeader(th, "login", width)) | |
| 72 | + | lines = append(lines, th.FG256(th.Muted, "choose login method (↑/↓, enter, esc to cancel):")) | |
| 73 | + | for i, o := range opts { | |
| 74 | + | plain := " " + o | |
| 75 | + | if i == d.cursor { | |
| 76 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 77 | + | } else { | |
| 78 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | lines = append(lines, frameRule(th, width)) | |
| 82 | + | case loginStepProvider: | |
| 83 | + | opts := []string{"anthropic", "openai"} | |
| 84 | + | lines = append(lines, frameHeader(th, "login · "+d.method, width)) | |
| 85 | + | lines = append(lines, th.FG256(th.Muted, "choose provider:")) | |
| 86 | + | for i, o := range opts { | |
| 87 | + | plain := " " + o | |
| 88 | + | if i == d.cursor { | |
| 89 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 90 | + | } else { | |
| 91 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | lines = append(lines, frameRule(th, width)) | |
| 95 | + | case loginStepWaiting: | |
| 96 | + | lines = append(lines, frameHeader(th, "login · "+d.method+" · "+d.provider, width)) | |
| 97 | + | lines = append(lines, th.FG256(th.FG, "opening browser...")) | |
| 98 | + | lines = append(lines, th.FG256(th.Muted, d.url)) | |
| 99 | + | lines = append(lines, "") | |
| 100 | + | lines = append(lines, th.FG256(th.Muted, "waiting for callback. press esc to cancel.")) | |
| 101 | + | lines = append(lines, frameRule(th, width)) | |
| 102 | + | case loginStepDone: | |
| 103 | + | title := "login · failed" | |
| 104 | + | body := th.FG256(th.Error, d.message) | |
| 105 | + | if d.success { | |
| 106 | + | title = "login · success" | |
| 107 | + | body = th.FG256(th.Tool, fmt.Sprintf("logged in to %s via %s", d.provider, d.method)) | |
| 108 | + | } | |
| 109 | + | lines = append(lines, frameHeader(th, title, width)) | |
| 110 | + | lines = append(lines, body) | |
| 111 | + | lines = append(lines, th.FG256(th.Muted, "press any key to close")) | |
| 112 | + | lines = append(lines, frameRule(th, width)) | |
| 113 | + | } | |
| 114 | + | return lines | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Key is the result of handling a key press. | |
| 118 | + | type loginDialogAction struct { | |
| 119 | + | StartAPIKey bool | |
| 120 | + | StartOAuth bool | |
| 121 | + | Provider string | |
| 122 | + | Close bool | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 126 | + | func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |
| 127 | + | switch d.step { | |
| 128 | + | case loginStepMethod: | |
| 129 | + | return d.handleMethodKey(k) | |
| 130 | + | case loginStepProvider: | |
| 131 | + | return d.handleProviderKey(k) | |
| 132 | + | case loginStepWaiting: | |
| 133 | + | if k.Kind == tui.KeyEsc { | |
| 134 | + | d.Close() | |
| 135 | + | return loginDialogAction{Close: true} | |
| 136 | + | } | |
| 137 | + | case loginStepDone: | |
| 138 | + | d.Close() | |
| 139 | + | return loginDialogAction{Close: true} | |
| 140 | + | } | |
| 141 | + | return loginDialogAction{} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction { | |
| 145 | + | max := 2 | |
| 146 | + | switch k.Kind { | |
| 147 | + | case tui.KeyUp: | |
| 148 | + | if d.cursor > 0 { | |
| 149 | + | d.cursor-- | |
| 150 | + | } | |
| 151 | + | case tui.KeyDown: | |
| 152 | + | if d.cursor < max-1 { | |
| 153 | + | d.cursor++ | |
| 154 | + | } | |
| 155 | + | case tui.KeyEsc: | |
| 156 | + | d.Close() | |
| 157 | + | return loginDialogAction{Close: true} | |
| 158 | + | case tui.KeyEnter: | |
| 159 | + | if d.cursor == 0 { | |
| 160 | + | d.method = "apikey" | |
| 161 | + | } else { | |
| 162 | + | d.method = "oauth" | |
| 163 | + | } | |
| 164 | + | d.step = loginStepProvider | |
| 165 | + | d.cursor = 0 | |
| 166 | + | } | |
| 167 | + | return loginDialogAction{} | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |
| 171 | + | switch k.Kind { | |
| 172 | + | case tui.KeyUp: | |
| 173 | + | if d.cursor > 0 { | |
| 174 | + | d.cursor-- | |
| 175 | + | } | |
| 176 | + | case tui.KeyDown: | |
| 177 | + | if d.cursor < 1 { | |
| 178 | + | d.cursor++ | |
| 179 | + | } | |
| 180 | + | case tui.KeyEsc: | |
| 181 | + | d.Close() | |
| 182 | + | return loginDialogAction{Close: true} | |
| 183 | + | case tui.KeyEnter: | |
| 184 | + | providers := []string{"anthropic", "openai"} | |
| 185 | + | d.provider = providers[d.cursor] | |
| 186 | + | d.step = loginStepWaiting | |
| 187 | + | if d.method == "apikey" { | |
| 188 | + | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| 189 | + | } | |
| 190 | + | return loginDialogAction{StartOAuth: true, Provider: d.provider} | |
| 191 | + | } | |
| 192 | + | return loginDialogAction{} | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // ShowWaiting transitions to the waiting state with the given URL. | |
| 196 | + | // No-op if the user has already dismissed the dialog. | |
| 197 | + | func (d *loginDialog) ShowWaiting(url string) { | |
| 198 | + | if d.step == loginStepClosed { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | d.step = loginStepWaiting | |
| 202 | + | d.url = url | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // ShowResult transitions to the done state with the given outcome. | |
| 206 | + | // No-op if the user has already dismissed the dialog. | |
| 207 | + | func (d *loginDialog) ShowResult(success bool, message string) { | |
| 208 | + | if d.step == loginStepClosed { | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | d.step = loginStepDone | |
| 212 | + | d.success = success | |
| 213 | + | d.message = message | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func max0(x int) int { | |
| 217 | + | if x < 0 { | |
| 218 | + | return 0 | |
| 219 | + | } | |
| 220 | + | return x | |
| 221 | + | } | |
A internal/agent/modes/model_dialog.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 7 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // modelDialog is an inline picker for choosing the active model. | |
| 11 | + | // It lists all models known to the provider package (baked-in catalog | |
| 12 | + | // + any live entries discovered via /v1/models) and lets the user pick | |
| 13 | + | // one with arrow keys + enter. | |
| 14 | + | type modelDialog struct { | |
| 15 | + | active bool | |
| 16 | + | models []provider.Model | |
| 17 | + | cursor int | |
| 18 | + | current string // currently selected model id (highlighted) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // modelDialogAction is returned by HandleKey. | |
| 22 | + | type modelDialogAction struct { | |
| 23 | + | Select bool | |
| 24 | + | Provider string | |
| 25 | + | Model string | |
| 26 | + | Close bool | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newModelDialog() *modelDialog { | |
| 30 | + | return &modelDialog{} | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Open shows the dialog. current is the currently active model id so | |
| 34 | + | // it can be pre-selected. | |
| 35 | + | func (d *modelDialog) Open(current string) { | |
| 36 | + | d.active = true | |
| 37 | + | d.models = provider.Active() | |
| 38 | + | d.current = current | |
| 39 | + | d.cursor = 0 | |
| 40 | + | for i, m := range d.models { | |
| 41 | + | if m.ID == current { | |
| 42 | + | d.cursor = i | |
| 43 | + | break | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Close hides the dialog. | |
| 49 | + | func (d *modelDialog) Close() { d.active = false } | |
| 50 | + | ||
| 51 | + | // Active reports whether the dialog is visible and consumes input. | |
| 52 | + | func (d *modelDialog) Active() bool { return d != nil && d.active } | |
| 53 | + | ||
| 54 | + | // Render returns the dialog lines. | |
| 55 | + | func (d *modelDialog) Render(th tui.Theme, width int) []string { | |
| 56 | + | if !d.Active() { | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | var lines []string | |
| 60 | + | lines = append(lines, frameHeader(th, "model", width)) | |
| 61 | + | lines = append(lines, th.FG256(th.Muted, "pick a model (↑/↓, enter, esc to cancel)")) | |
| 62 | + | ||
| 63 | + | // Simple scroll window so very tall catalogs still fit in a short tui. | |
| 64 | + | const visible = 14 | |
| 65 | + | start := 0 | |
| 66 | + | end := len(d.models) | |
| 67 | + | if end > visible { | |
| 68 | + | start = d.cursor - visible/2 | |
| 69 | + | if start < 0 { | |
| 70 | + | start = 0 | |
| 71 | + | } | |
| 72 | + | if start+visible > end { | |
| 73 | + | start = end - visible | |
| 74 | + | } | |
| 75 | + | end = start + visible | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | for i := start; i < end; i++ { | |
| 79 | + | m := d.models[i] | |
| 80 | + | prov := m.Provider | |
| 81 | + | id := m.ID | |
| 82 | + | reason := " " | |
| 83 | + | if m.Reasoning { | |
| 84 | + | reason = "✦" | |
| 85 | + | } | |
| 86 | + | name := m.DisplayName | |
| 87 | + | tag := "" | |
| 88 | + | switch { | |
| 89 | + | case m.Speculative: | |
| 90 | + | tag = "[speculative] " | |
| 91 | + | case m.Source == "live": | |
| 92 | + | tag = "[live] " | |
| 93 | + | } | |
| 94 | + | curMark := " " | |
| 95 | + | if m.ID == d.current { | |
| 96 | + | curMark = "● " | |
| 97 | + | } | |
| 98 | + | plain := fmt.Sprintf(" %s%-10s %-28s %s %s%s", curMark, prov, id, reason, tag, name) | |
| 99 | + | if i == d.cursor { | |
| 100 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 101 | + | } else { | |
| 102 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if start > 0 { | |
| 107 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more above", start))) | |
| 108 | + | } | |
| 109 | + | if end < len(d.models) { | |
| 110 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" … %d more below", len(d.models)-end))) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | lines = append(lines, frameRule(th, width)) | |
| 114 | + | return lines | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 118 | + | func (d *modelDialog) HandleKey(k tui.Key) modelDialogAction { | |
| 119 | + | switch k.Kind { | |
| 120 | + | case tui.KeyUp: | |
| 121 | + | if d.cursor > 0 { | |
| 122 | + | d.cursor-- | |
| 123 | + | } | |
| 124 | + | case tui.KeyDown: | |
| 125 | + | if d.cursor < len(d.models)-1 { | |
| 126 | + | d.cursor++ | |
| 127 | + | } | |
| 128 | + | case tui.KeyEsc: | |
| 129 | + | d.Close() | |
| 130 | + | return modelDialogAction{Close: true} | |
| 131 | + | case tui.KeyEnter: | |
| 132 | + | if len(d.models) == 0 { | |
| 133 | + | d.Close() | |
| 134 | + | return modelDialogAction{Close: true} | |
| 135 | + | } | |
| 136 | + | m := d.models[d.cursor] | |
| 137 | + | d.Close() | |
| 138 | + | return modelDialogAction{Select: true, Provider: m.Provider, Model: m.ID} | |
| 139 | + | } | |
| 140 | + | return modelDialogAction{} | |
| 141 | + | } | |
A internal/agent/modes/print.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | // Package modes implements zot's three run modes: print, json, interactive. | |
| 2 | + | package modes | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/internal/core" | |
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // RunPrint runs the agent to completion and writes only the final | |
| 15 | + | // assistant text block to out. Exit code comes from the caller. | |
| 16 | + | func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | |
| 17 | + | var finalText strings.Builder | |
| 18 | + | var lastAssistant string | |
| 19 | + | var runErr error | |
| 20 | + | ||
| 21 | + | sink := func(ev core.AgentEvent) { | |
| 22 | + | switch e := ev.(type) { | |
| 23 | + | case core.EvAssistantMessage: | |
| 24 | + | // Keep the most recent assistant text block; by the end it's the final answer. | |
| 25 | + | var sb strings.Builder | |
| 26 | + | for _, c := range e.Message.Content { | |
| 27 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 28 | + | if sb.Len() > 0 { | |
| 29 | + | sb.WriteString("\n") | |
| 30 | + | } | |
| 31 | + | sb.WriteString(tb.Text) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | if sb.Len() > 0 { | |
| 35 | + | lastAssistant = sb.String() | |
| 36 | + | } | |
| 37 | + | case core.EvTurnEnd: | |
| 38 | + | if e.Err != nil { | |
| 39 | + | runErr = e.Err | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | if runErr != nil { | |
| 48 | + | return runErr | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | finalText.WriteString(lastAssistant) | |
| 52 | + | if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") { | |
| 53 | + | finalText.WriteString("\n") | |
| 54 | + | } | |
| 55 | + | _, err := fmt.Fprint(out, finalText.String()) | |
| 56 | + | return err | |
| 57 | + | } | |
A internal/agent/modes/session_dialog.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/core" | |
| 9 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // sessionDialog is the inline picker shown when the user runs /sessions. | |
| 13 | + | type sessionDialog struct { | |
| 14 | + | active bool | |
| 15 | + | sessions []core.SessionSummary | |
| 16 | + | cursor int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // sessionDialogAction is returned by HandleKey. | |
| 20 | + | type sessionDialogAction struct { | |
| 21 | + | Select bool | |
| 22 | + | Path string | |
| 23 | + | Close bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func newSessionDialog() *sessionDialog { return &sessionDialog{} } | |
| 27 | + | ||
| 28 | + | // Open populates the dialog from root + cwd and shows it. | |
| 29 | + | func (d *sessionDialog) Open(root, cwd string) { | |
| 30 | + | d.sessions = core.DescribeSessions(root, cwd) | |
| 31 | + | d.cursor = 0 | |
| 32 | + | d.active = true | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Close hides the dialog. | |
| 36 | + | func (d *sessionDialog) Close() { d.active = false } | |
| 37 | + | ||
| 38 | + | // Active reports whether the dialog is visible and consumes input. | |
| 39 | + | func (d *sessionDialog) Active() bool { return d != nil && d.active } | |
| 40 | + | ||
| 41 | + | // Render returns the dialog lines. | |
| 42 | + | func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |
| 43 | + | if !d.Active() { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | var lines []string | |
| 47 | + | lines = append(lines, frameHeader(th, "sessions", width)) | |
| 48 | + | if len(d.sessions) == 0 { | |
| 49 | + | lines = append(lines, th.FG256(th.Muted, "no previous sessions for this directory")) | |
| 50 | + | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |
| 51 | + | lines = append(lines, frameRule(th, width)) | |
| 52 | + | return lines | |
| 53 | + | } | |
| 54 | + | lines = append(lines, th.FG256(th.Muted, "pick a session to resume (↑/↓, enter, esc to cancel)")) | |
| 55 | + | for i, s := range d.sessions { | |
| 56 | + | plain := " " + formatSessionRowPlain(s, width-2) | |
| 57 | + | if i == d.cursor { | |
| 58 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 59 | + | } else { | |
| 60 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | lines = append(lines, frameRule(th, width)) | |
| 64 | + | return lines | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // formatSessionRowPlain returns the session row body without any ANSI | |
| 68 | + | // styling so the caller can wrap it in either a plain mute color or a | |
| 69 | + | // full-row selection highlight. | |
| 70 | + | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |
| 71 | + | when := formatRelative(s.Started) | |
| 72 | + | summary := strings.TrimSpace(s.FirstUserText) | |
| 73 | + | if summary == "" { | |
| 74 | + | summary = "(empty)" | |
| 75 | + | } | |
| 76 | + | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | |
| 77 | + | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | |
| 78 | + | room := maxWidth - len(left) | |
| 79 | + | if room < 10 { | |
| 80 | + | room = 10 | |
| 81 | + | } | |
| 82 | + | if len(summary) > room { | |
| 83 | + | summary = summary[:room-1] + "…" | |
| 84 | + | } | |
| 85 | + | summary = strings.ReplaceAll(summary, "\n", " ") | |
| 86 | + | return left + summary | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func formatRelative(t time.Time) string { | |
| 90 | + | if t.IsZero() { | |
| 91 | + | return "unknown" | |
| 92 | + | } | |
| 93 | + | d := time.Since(t) | |
| 94 | + | switch { | |
| 95 | + | case d < time.Minute: | |
| 96 | + | return "just now" | |
| 97 | + | case d < time.Hour: | |
| 98 | + | return fmt.Sprintf("%d min ago", int(d.Minutes())) | |
| 99 | + | case d < 24*time.Hour: | |
| 100 | + | return fmt.Sprintf("%d h ago", int(d.Hours())) | |
| 101 | + | case d < 7*24*time.Hour: | |
| 102 | + | return fmt.Sprintf("%d d ago", int(d.Hours()/24)) | |
| 103 | + | default: | |
| 104 | + | return t.Local().Format("2006-01-02") | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // HandleKey advances the dialog and returns an action to apply, if any. | |
| 109 | + | func (d *sessionDialog) HandleKey(k tui.Key) sessionDialogAction { | |
| 110 | + | switch k.Kind { | |
| 111 | + | case tui.KeyUp: | |
| 112 | + | if d.cursor > 0 { | |
| 113 | + | d.cursor-- | |
| 114 | + | } | |
| 115 | + | case tui.KeyDown: | |
| 116 | + | if d.cursor < len(d.sessions)-1 { | |
| 117 | + | d.cursor++ | |
| 118 | + | } | |
| 119 | + | case tui.KeyEsc: | |
| 120 | + | d.Close() | |
| 121 | + | return sessionDialogAction{Close: true} | |
| 122 | + | case tui.KeyEnter: | |
| 123 | + | if len(d.sessions) == 0 { | |
| 124 | + | d.Close() | |
| 125 | + | return sessionDialogAction{Close: true} | |
| 126 | + | } | |
| 127 | + | s := d.sessions[d.cursor] | |
| 128 | + | d.Close() | |
| 129 | + | return sessionDialogAction{Select: true, Path: s.Path} | |
| 130 | + | } | |
| 131 | + | return sessionDialogAction{} | |
| 132 | + | } | |
A internal/agent/modes/slash_suggest.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // slashCommand is one entry in the autocomplete popup. | |
| 10 | + | type slashCommand struct { | |
| 11 | + | Name string // with leading "/" | |
| 12 | + | Desc string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // slashCatalog lists every slash command the interactive mode handles. | |
| 16 | + | // Keep in sync with runSlash(). | |
| 17 | + | var slashCatalog = []slashCommand{ | |
| 18 | + | {"/help", "show key bindings and commands"}, | |
| 19 | + | {"/login", "log in via api key or subscription"}, | |
| 20 | + | {"/logout", "clear a provider's credentials"}, | |
| 21 | + | {"/model", "pick a model (or /model <id>)"}, | |
| 22 | + | {"/sessions", "resume a previous session for this directory"}, | |
| 23 | + | {"/compact", "summarize and replace the transcript to free up context"}, | |
| 24 | + | {"/lock", "confine tools to the current directory"}, | |
| 25 | + | {"/unlock", "allow tools to touch paths outside this directory"}, | |
| 26 | + | {"/clear", "clear the chat transcript"}, | |
| 27 | + | {"/exit", "exit zot"}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // slashSuggester renders the popup that appears when the editor starts | |
| 31 | + | // with "/". It does not own any input state — the editor drives. | |
| 32 | + | type slashSuggester struct { | |
| 33 | + | cursor int | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // looksLikeSlashCommand reports whether text is an attempt at a slash | |
| 37 | + | // command (valid or not). Returns true for things like "/foo" or | |
| 38 | + | // "/bar baz" but false for paths ("/Users/pat/...") and regexes | |
| 39 | + | // ("/foo.bar/") so those can be sent to the model as-is. | |
| 40 | + | // | |
| 41 | + | // The head after "/" must be a single simple word: only letters, | |
| 42 | + | // digits, hyphens, and underscores. That excludes paths (contain "/"), | |
| 43 | + | // regexes (contain "."), and URLs. | |
| 44 | + | func looksLikeSlashCommand(text string) bool { | |
| 45 | + | text = strings.TrimSpace(text) | |
| 46 | + | if len(text) < 2 || text[0] != '/' { | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | head := text[1:] | |
| 50 | + | if i := strings.IndexAny(head, " \t\n"); i >= 0 { | |
| 51 | + | head = head[:i] | |
| 52 | + | } | |
| 53 | + | if head == "" { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | for _, r := range head { | |
| 57 | + | if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && | |
| 58 | + | !(r >= '0' && r <= '9') && r != '-' && r != '_' { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // isKnownSlashCommand reports whether text's head matches a registered | |
| 66 | + | // slash command name in slashCatalog. | |
| 67 | + | func isKnownSlashCommand(text string) bool { | |
| 68 | + | text = strings.TrimSpace(text) | |
| 69 | + | if text == "" || text[0] != '/' { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | head := text | |
| 73 | + | if i := strings.IndexAny(text, " \t\n"); i >= 0 { | |
| 74 | + | head = text[:i] | |
| 75 | + | } | |
| 76 | + | for _, c := range slashCatalog { | |
| 77 | + | if c.Name == head { | |
| 78 | + | return true | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | return false | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func newSlashSuggester() *slashSuggester { return &slashSuggester{} } | |
| 85 | + | ||
| 86 | + | // matches returns the commands whose name has input as a prefix. | |
| 87 | + | // If input is just "/", everything is shown. | |
| 88 | + | func (s *slashSuggester) matches(input string) []slashCommand { | |
| 89 | + | input = strings.TrimRight(input, " ") | |
| 90 | + | if input == "" || !strings.HasPrefix(input, "/") { | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | // If there is a space, the user has moved past the command name. | |
| 94 | + | if idx := strings.IndexByte(input, ' '); idx >= 0 { | |
| 95 | + | return nil | |
| 96 | + | } | |
| 97 | + | var out []slashCommand | |
| 98 | + | for _, c := range slashCatalog { | |
| 99 | + | if strings.HasPrefix(c.Name, input) { | |
| 100 | + | out = append(out, c) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return out | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // clampCursor keeps the cursor inside the current match list. | |
| 107 | + | func (s *slashSuggester) clampCursor(n int) { | |
| 108 | + | if n <= 0 { | |
| 109 | + | s.cursor = 0 | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | if s.cursor < 0 { | |
| 113 | + | s.cursor = 0 | |
| 114 | + | } | |
| 115 | + | if s.cursor >= n { | |
| 116 | + | s.cursor = n - 1 | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Up / Down navigate the suggestion list. | |
| 121 | + | func (s *slashSuggester) Up() { s.cursor-- } | |
| 122 | + | func (s *slashSuggester) Down() { s.cursor++ } | |
| 123 | + | ||
| 124 | + | // Active reports whether the popup is visible for the given input. | |
| 125 | + | func (s *slashSuggester) Active(input string) bool { | |
| 126 | + | return len(s.matches(input)) > 0 | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Selection returns the currently highlighted command for input, or "". | |
| 130 | + | func (s *slashSuggester) Selection(input string) string { | |
| 131 | + | m := s.matches(input) | |
| 132 | + | if len(m) == 0 { | |
| 133 | + | return "" | |
| 134 | + | } | |
| 135 | + | s.clampCursor(len(m)) | |
| 136 | + | return m[s.cursor].Name | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Render returns the popup lines or nil. | |
| 140 | + | func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string { | |
| 141 | + | m := s.matches(input) | |
| 142 | + | if len(m) == 0 { | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | s.clampCursor(len(m)) | |
| 146 | + | var lines []string | |
| 147 | + | for i, c := range m { | |
| 148 | + | name := c.Name | |
| 149 | + | if len(name) < 10 { | |
| 150 | + | name = name + strings.Repeat(" ", 10-len(name)) | |
| 151 | + | } | |
| 152 | + | plain := " " + name + " " + c.Desc | |
| 153 | + | if i == s.cursor { | |
| 154 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 155 | + | } else { | |
| 156 | + | lines = append(lines, th.FG256(th.Muted, plain)) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | lines = append(lines, th.FG256(th.Muted, " ↑/↓ navigate · tab complete · enter run")) | |
| 160 | + | return lines | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Reset puts the cursor back to the first match. Call this whenever the | |
| 164 | + | // input changes in a way that reshapes the match list. | |
| 165 | + | func (s *slashSuggester) Reset() { s.cursor = 0 } | |
A internal/agent/modes/spinner.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math/rand" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // spinner drives the busy animation shown in the status bar while a | |
| 9 | + | // turn is streaming. It rotates through a list of playful status | |
| 10 | + | // messages and a small frame animation. | |
| 11 | + | type spinner struct { | |
| 12 | + | frames []string | |
| 13 | + | messages []string | |
| 14 | + | startedAt time.Time | |
| 15 | + | msgIdx int | |
| 16 | + | lastSwap time.Time | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // funnyWorkingLines is the rotating text. Kept deliberately short so it | |
| 20 | + | // fits next to the token counter on narrow terminals. | |
| 21 | + | var funnyWorkingLines = []string{ | |
| 22 | + | "thinking", | |
| 23 | + | "reticulating splines", | |
| 24 | + | "bribing the tokenizer", | |
| 25 | + | "asking the rubber duck", | |
| 26 | + | "summoning daemons", | |
| 27 | + | "consulting the oracle", | |
| 28 | + | "herding tokens", | |
| 29 | + | "compiling excuses", | |
| 30 | + | "poking the model", | |
| 31 | + | "negotiating with rate limits", | |
| 32 | + | "picking a fight with syntax", | |
| 33 | + | "reading between the bits", | |
| 34 | + | "tasting the semicolons", | |
| 35 | + | "pretending to understand go generics", | |
| 36 | + | "petting the cache", | |
| 37 | + | "drafting clever replies", | |
| 38 | + | "warming up the GPU choir", | |
| 39 | + | "arguing with a stack trace", | |
| 40 | + | "googling the answer (not really)", | |
| 41 | + | "rewriting history", | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // spinnerFrames is a smooth braille spinner. | |
| 45 | + | var spinnerFrames = []string{"\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"} | |
| 46 | + | ||
| 47 | + | // newSpinner constructs a fresh spinner. | |
| 48 | + | func newSpinner() *spinner { | |
| 49 | + | s := &spinner{ | |
| 50 | + | frames: spinnerFrames, | |
| 51 | + | messages: funnyWorkingLines, | |
| 52 | + | } | |
| 53 | + | return s | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Start resets the spinner to the beginning of its animation and picks | |
| 57 | + | // a random opening message. | |
| 58 | + | func (s *spinner) Start() { | |
| 59 | + | s.startedAt = time.Now() | |
| 60 | + | s.msgIdx = rand.Intn(len(s.messages)) | |
| 61 | + | s.lastSwap = s.startedAt | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Frame returns the current spinner glyph for the running animation. | |
| 65 | + | func (s *spinner) Frame() string { | |
| 66 | + | if s.startedAt.IsZero() { | |
| 67 | + | return s.frames[0] | |
| 68 | + | } | |
| 69 | + | elapsed := time.Since(s.startedAt) | |
| 70 | + | // 120ms per frame matches the interactive tick rate. | |
| 71 | + | idx := int(elapsed/(120*time.Millisecond)) % len(s.frames) | |
| 72 | + | return s.frames[idx] | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Message returns the current rotating status text. The text changes | |
| 76 | + | // every ~2.5 seconds so the spinner doesn't look frozen. | |
| 77 | + | func (s *spinner) Message() string { | |
| 78 | + | if time.Since(s.lastSwap) > 2500*time.Millisecond { | |
| 79 | + | s.msgIdx = (s.msgIdx + 1) % len(s.messages) | |
| 80 | + | s.lastSwap = time.Now() | |
| 81 | + | } | |
| 82 | + | return s.messages[s.msgIdx] | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Elapsed returns the wall-clock duration the spinner has been running. | |
| 86 | + | func (s *spinner) Elapsed() time.Duration { | |
| 87 | + | if s.startedAt.IsZero() { | |
| 88 | + | return 0 | |
| 89 | + | } | |
| 90 | + | return time.Since(s.startedAt).Round(time.Second) | |
| 91 | + | } | |
A internal/agent/modes/welcome.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "github.com/patriceckhart/zot/internal/tui" | |
| 4 | + | ||
| 5 | + | // welcomeBanner returns the intro text shown at the top of an empty chat. | |
| 6 | + | // It uses the `zot` label color (same as the assistant) for consistency. | |
| 7 | + | func welcomeBanner(th tui.Theme) []string { | |
| 8 | + | return []string{ | |
| 9 | + | th.FG256(th.Assistant, tui.Bold("▌ i'm zot. yet another coding agent harness.")), | |
| 10 | + | th.FG256(th.Muted, " ask anything, or type /help to see commands."), | |
| 11 | + | "", | |
| 12 | + | } | |
| 13 | + | } | |
A internal/agent/systemprompt.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ToolSummary is a name+one-line description, used when rendering the | |
| 10 | + | // "available tools" section of the system prompt. | |
| 11 | + | type ToolSummary struct { | |
| 12 | + | Name string | |
| 13 | + | Description string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // SystemPromptOpts configures BuildSystemPrompt. | |
| 17 | + | type SystemPromptOpts struct { | |
| 18 | + | CWD string | |
| 19 | + | Tools []ToolSummary | |
| 20 | + | Custom string // if set, replaces the default identity/guidelines sections | |
| 21 | + | Append []string // extra text appended at the end | |
| 22 | + | Now time.Time | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // BuildSystemPrompt constructs the system prompt. | |
| 26 | + | func BuildSystemPrompt(o SystemPromptOpts) string { | |
| 27 | + | if o.Now.IsZero() { | |
| 28 | + | o.Now = time.Now() | |
| 29 | + | } | |
| 30 | + | date := o.Now.Format("2006-01-02") | |
| 31 | + | cwd := o.CWD | |
| 32 | + | if cwd == "" { | |
| 33 | + | cwd = "." | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | var sb strings.Builder | |
| 37 | + | ||
| 38 | + | if o.Custom != "" { | |
| 39 | + | sb.WriteString(o.Custom) | |
| 40 | + | } else { | |
| 41 | + | sb.WriteString(defaultIdentity) | |
| 42 | + | sb.WriteString("\n\n") | |
| 43 | + | sb.WriteString(renderToolsSection(o.Tools)) | |
| 44 | + | sb.WriteString("\n") | |
| 45 | + | sb.WriteString(defaultGuidelines) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, a := range o.Append { | |
| 49 | + | if strings.TrimSpace(a) == "" { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | sb.WriteString("\n\n") | |
| 53 | + | sb.WriteString(a) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fmt.Fprintf(&sb, "\n\nCurrent date: %s\nCurrent working directory: %s\n", date, cwd) | |
| 57 | + | return sb.String() | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func renderToolsSection(tools []ToolSummary) string { | |
| 61 | + | if len(tools) == 0 { | |
| 62 | + | return "No tools are available in this session." | |
| 63 | + | } | |
| 64 | + | var sb strings.Builder | |
| 65 | + | sb.WriteString("You have the following tools available:\n") | |
| 66 | + | for _, t := range tools { | |
| 67 | + | fmt.Fprintf(&sb, "- %s: %s\n", t.Name, t.Description) | |
| 68 | + | } | |
| 69 | + | return sb.String() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | const defaultIdentity = `You are zot, a lightweight terminal coding assistant. | |
| 73 | + | You help a developer by reading files, writing files, editing files, and running shell commands. | |
| 74 | + | You are concise. You explain your plan briefly, then act. You do not apologize or hedge.` | |
| 75 | + | ||
| 76 | + | const defaultGuidelines = `Operating guidelines: | |
| 77 | + | - Prefer "edit" over "write" for existing files. Always read a file before editing it. | |
| 78 | + | - Before running "bash", explain what the command will do in one short sentence. | |
| 79 | + | - Avoid destructive commands (rm -rf, dropping tables, force-pushing, etc.) unless the user explicitly asks. | |
| 80 | + | - Keep shell commands non-interactive (pass -y / --yes where needed; pipe "yes" if required). | |
| 81 | + | - When unsure about a file's contents or structure, read it first rather than guess. | |
| 82 | + | - When you are done, reply with a short summary of what you changed and any commands the user should run.` | |
A internal/agent/tools/bash.go +207 -0
| @@ -0,0 +1,207 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "encoding/json" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "runtime" | |
| 15 | + | "strings" | |
| 16 | + | "syscall" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/patriceckhart/zot/internal/core" | |
| 20 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | maxBashLines = 2000 | |
| 25 | + | maxBashBytes = 50 * 1024 | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // BashTool runs a shell command in the agent's cwd. | |
| 29 | + | type BashTool struct { | |
| 30 | + | CWD string | |
| 31 | + | Sandbox *Sandbox | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type bashArgs struct { | |
| 35 | + | Command string `json:"command"` | |
| 36 | + | Timeout int `json:"timeout,omitempty"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const bashSchema = `{ | |
| 40 | + | "type":"object", | |
| 41 | + | "properties":{ | |
| 42 | + | "command":{"type":"string","description":"Shell command to execute."}, | |
| 43 | + | "timeout":{"type":"integer","description":"Timeout in seconds. No default timeout."} | |
| 44 | + | }, | |
| 45 | + | "required":["command"], | |
| 46 | + | "additionalProperties":false | |
| 47 | + | }` | |
| 48 | + | ||
| 49 | + | func (t *BashTool) Name() string { return "bash" } | |
| 50 | + | func (t *BashTool) Description() string { return "Run a shell command. stdout and stderr are merged." } | |
| 51 | + | func (t *BashTool) Schema() json.RawMessage { return json.RawMessage(bashSchema) } | |
| 52 | + | ||
| 53 | + | func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 54 | + | var a bashArgs | |
| 55 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 56 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 57 | + | } | |
| 58 | + | if strings.TrimSpace(a.Command) == "" { | |
| 59 | + | return core.ToolResult{}, fmt.Errorf("command is required") | |
| 60 | + | } | |
| 61 | + | if err := t.Sandbox.CheckCommand(a.Command); err != nil { | |
| 62 | + | return core.ToolResult{}, err | |
| 63 | + | } | |
| 64 | + | cwd := t.CWD | |
| 65 | + | if cwd == "" { | |
| 66 | + | cwd, _ = os.Getwd() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | runCtx := ctx | |
| 70 | + | var cancel context.CancelFunc | |
| 71 | + | if a.Timeout > 0 { | |
| 72 | + | runCtx, cancel = context.WithTimeout(ctx, time.Duration(a.Timeout)*time.Second) | |
| 73 | + | defer cancel() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | cmd := newShellCmd(runCtx, a.Command) | |
| 77 | + | cmd.Dir = cwd | |
| 78 | + | cmd.Env = os.Environ() | |
| 79 | + | ||
| 80 | + | // Capture merged stdout+stderr with line-by-line streaming. | |
| 81 | + | pr, pw := io.Pipe() | |
| 82 | + | cmd.Stdout = pw | |
| 83 | + | cmd.Stderr = pw | |
| 84 | + | ||
| 85 | + | if err := cmd.Start(); err != nil { | |
| 86 | + | return core.ToolResult{}, fmt.Errorf("start: %w", err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Writer to both the buffer (trimmed) and progress callback. | |
| 90 | + | captured := &bytes.Buffer{} | |
| 91 | + | done := make(chan struct{}) | |
| 92 | + | go func() { | |
| 93 | + | defer close(done) | |
| 94 | + | buf := make([]byte, 4096) | |
| 95 | + | for { | |
| 96 | + | n, err := pr.Read(buf) | |
| 97 | + | if n > 0 { | |
| 98 | + | chunk := buf[:n] | |
| 99 | + | if captured.Len() < maxBashBytes { | |
| 100 | + | room := maxBashBytes - captured.Len() | |
| 101 | + | if n > room { | |
| 102 | + | captured.Write(chunk[:room]) | |
| 103 | + | } else { | |
| 104 | + | captured.Write(chunk) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | if progress != nil { | |
| 108 | + | progress(string(chunk)) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | if err != nil { | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | }() | |
| 116 | + | ||
| 117 | + | waitErr := cmd.Wait() | |
| 118 | + | pw.Close() | |
| 119 | + | <-done | |
| 120 | + | ||
| 121 | + | // On hard cancel, try to kill the process group. | |
| 122 | + | if ctx.Err() != nil && cmd.Process != nil { | |
| 123 | + | killProcessGroup(cmd) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | output := captured.String() | |
| 127 | + | truncBytes := captured.Len() >= maxBashBytes | |
| 128 | + | lines := strings.Split(output, "\n") | |
| 129 | + | truncLines := false | |
| 130 | + | if len(lines) > maxBashLines { | |
| 131 | + | lines = lines[:maxBashLines] | |
| 132 | + | truncLines = true | |
| 133 | + | } | |
| 134 | + | trimmed := strings.Join(lines, "\n") | |
| 135 | + | ||
| 136 | + | exitCode := 0 | |
| 137 | + | if waitErr != nil { | |
| 138 | + | if ee, ok := waitErr.(*exec.ExitError); ok { | |
| 139 | + | exitCode = ee.ExitCode() | |
| 140 | + | } else { | |
| 141 | + | exitCode = -1 | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | var sb strings.Builder | |
| 146 | + | if trimmed != "" { | |
| 147 | + | sb.WriteString(trimmed) | |
| 148 | + | if !strings.HasSuffix(trimmed, "\n") { | |
| 149 | + | sb.WriteString("\n") | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | if truncLines { | |
| 153 | + | fmt.Fprintf(&sb, "... [truncated at %d lines]\n", maxBashLines) | |
| 154 | + | } | |
| 155 | + | if truncBytes { | |
| 156 | + | fmt.Fprintf(&sb, "... [truncated at %d bytes]\n", maxBashBytes) | |
| 157 | + | } | |
| 158 | + | sb.WriteString(fmt.Sprintf("[exit %d]", exitCode)) | |
| 159 | + | ||
| 160 | + | var fullPath string | |
| 161 | + | if truncBytes || truncLines { | |
| 162 | + | fullPath = writeFullOutput(output) | |
| 163 | + | if fullPath != "" { | |
| 164 | + | fmt.Fprintf(&sb, " (full output: %s)", fullPath) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | isErr := exitCode != 0 || ctx.Err() != nil | |
| 169 | + | return core.ToolResult{ | |
| 170 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 171 | + | IsError: isErr, | |
| 172 | + | Details: map[string]any{ | |
| 173 | + | "exit_code": exitCode, | |
| 174 | + | "full_output_path": fullPath, | |
| 175 | + | "lines_truncated": truncLines, | |
| 176 | + | "bytes_truncated": truncBytes, | |
| 177 | + | }, | |
| 178 | + | }, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func writeFullOutput(s string) string { | |
| 182 | + | b := make([]byte, 6) | |
| 183 | + | _, _ = rand.Read(b) | |
| 184 | + | name := filepath.Join(os.TempDir(), "zot-bash-"+hex.EncodeToString(b)+".log") | |
| 185 | + | if err := os.WriteFile(name, []byte(s), 0o600); err != nil { | |
| 186 | + | return "" | |
| 187 | + | } | |
| 188 | + | return name | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func newShellCmd(ctx context.Context, command string) *exec.Cmd { | |
| 192 | + | if runtime.GOOS == "windows" { | |
| 193 | + | return exec.CommandContext(ctx, "cmd", "/C", command) | |
| 194 | + | } | |
| 195 | + | return exec.CommandContext(ctx, "/bin/sh", "-c", command) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // killProcessGroup best-effort SIGTERM then SIGKILL. | |
| 199 | + | func killProcessGroup(cmd *exec.Cmd) { | |
| 200 | + | if cmd.Process == nil { | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | _ = cmd.Process.Signal(syscall.SIGTERM) | |
| 204 | + | time.AfterFunc(3*time.Second, func() { | |
| 205 | + | _ = cmd.Process.Kill() | |
| 206 | + | }) | |
| 207 | + | } | |
A internal/agent/tools/edit.go +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/core" | |
| 12 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // EditTool applies one or more exact-match replacements to a file. | |
| 16 | + | type EditTool struct { | |
| 17 | + | CWD string | |
| 18 | + | Sandbox *Sandbox | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type editOp struct { | |
| 22 | + | OldText string `json:"oldText"` | |
| 23 | + | NewText string `json:"newText"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type editArgs struct { | |
| 27 | + | Path string `json:"path"` | |
| 28 | + | Edits []editOp `json:"edits"` | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const editSchema = `{ | |
| 32 | + | "type":"object", | |
| 33 | + | "properties":{ | |
| 34 | + | "path":{"type":"string","description":"Path to the file to edit (relative or absolute)"}, | |
| 35 | + | "edits":{ | |
| 36 | + | "type":"array", | |
| 37 | + | "description":"One or more targeted replacements. Each oldText must match exactly once in the original file.", | |
| 38 | + | "items":{ | |
| 39 | + | "type":"object", | |
| 40 | + | "properties":{ | |
| 41 | + | "oldText":{"type":"string","description":"Exact text to replace. Must be unique in the file."}, | |
| 42 | + | "newText":{"type":"string","description":"Replacement text."} | |
| 43 | + | }, | |
| 44 | + | "required":["oldText","newText"], | |
| 45 | + | "additionalProperties":false | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | }, | |
| 49 | + | "required":["path","edits"], | |
| 50 | + | "additionalProperties":false | |
| 51 | + | }` | |
| 52 | + | ||
| 53 | + | func (t *EditTool) Name() string { return "edit" } | |
| 54 | + | func (t *EditTool) Description() string { | |
| 55 | + | return "Edit an existing file via exact-match replacements. Preserves line endings and BOM." | |
| 56 | + | } | |
| 57 | + | func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) } | |
| 58 | + | ||
| 59 | + | func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 60 | + | var a editArgs | |
| 61 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 62 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 63 | + | } | |
| 64 | + | if a.Path == "" { | |
| 65 | + | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 66 | + | } | |
| 67 | + | if len(a.Edits) == 0 { | |
| 68 | + | return core.ToolResult{}, fmt.Errorf("at least one edit is required") | |
| 69 | + | } | |
| 70 | + | path := resolvePath(t.CWD, a.Path) | |
| 71 | + | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 72 | + | return core.ToolResult{}, err | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | orig, err := os.ReadFile(path) | |
| 76 | + | if err != nil { | |
| 77 | + | return core.ToolResult{}, err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Detect BOM and line endings. | |
| 81 | + | var bom []byte | |
| 82 | + | if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) { | |
| 83 | + | bom = orig[:3] | |
| 84 | + | orig = orig[3:] | |
| 85 | + | } | |
| 86 | + | nl := detectLineEnding(orig) | |
| 87 | + | // Normalize to \n for matching. | |
| 88 | + | body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n"))) | |
| 89 | + | ||
| 90 | + | // Validate all edits first (against original content, not sequentially). | |
| 91 | + | for i, e := range a.Edits { | |
| 92 | + | if e.OldText == "" { | |
| 93 | + | return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1) | |
| 94 | + | } | |
| 95 | + | if e.OldText == e.NewText { | |
| 96 | + | return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1) | |
| 97 | + | } | |
| 98 | + | count := strings.Count(body, e.OldText) | |
| 99 | + | if count == 0 { | |
| 100 | + | return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path) | |
| 101 | + | } | |
| 102 | + | if count > 1 { | |
| 103 | + | return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Apply atomically (sorted by position so offsets stay valid as we splice). | |
| 108 | + | type span struct { | |
| 109 | + | start, end int | |
| 110 | + | replacement string | |
| 111 | + | } | |
| 112 | + | spans := make([]span, 0, len(a.Edits)) | |
| 113 | + | for _, e := range a.Edits { | |
| 114 | + | idx := strings.Index(body, e.OldText) | |
| 115 | + | spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText}) | |
| 116 | + | } | |
| 117 | + | // Check for overlaps. | |
| 118 | + | for i := 0; i < len(spans); i++ { | |
| 119 | + | for j := i + 1; j < len(spans); j++ { | |
| 120 | + | if spans[i].start < spans[j].end && spans[j].start < spans[i].end { | |
| 121 | + | return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit") | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | // Sort ascending. | |
| 126 | + | for i := 1; i < len(spans); i++ { | |
| 127 | + | for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- { | |
| 128 | + | spans[j-1], spans[j] = spans[j], spans[j-1] | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | var out strings.Builder | |
| 132 | + | prev := 0 | |
| 133 | + | for _, s := range spans { | |
| 134 | + | out.WriteString(body[prev:s.start]) | |
| 135 | + | out.WriteString(s.replacement) | |
| 136 | + | prev = s.end | |
| 137 | + | } | |
| 138 | + | out.WriteString(body[prev:]) | |
| 139 | + | newBody := out.String() | |
| 140 | + | ||
| 141 | + | // Restore line endings. | |
| 142 | + | if nl == "\r\n" { | |
| 143 | + | newBody = strings.ReplaceAll(newBody, "\n", "\r\n") | |
| 144 | + | } | |
| 145 | + | final := append([]byte{}, bom...) | |
| 146 | + | final = append(final, []byte(newBody)...) | |
| 147 | + | ||
| 148 | + | if err := os.WriteFile(path, final, 0o644); err != nil { | |
| 149 | + | return core.ToolResult{}, err | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n")) | |
| 153 | + | msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path) | |
| 154 | + | return core.ToolResult{ | |
| 155 | + | Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}}, | |
| 156 | + | Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff}, | |
| 157 | + | }, nil | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func detectLineEnding(b []byte) string { | |
| 161 | + | if bytes.Contains(b, []byte("\r\n")) { | |
| 162 | + | return "\r\n" | |
| 163 | + | } | |
| 164 | + | return "\n" | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // unifiedDiff is a minimal unified diff good enough for tool output. | |
| 168 | + | func unifiedDiff(name, a, b string) string { | |
| 169 | + | if a == b { | |
| 170 | + | return "" | |
| 171 | + | } | |
| 172 | + | aLines := strings.Split(a, "\n") | |
| 173 | + | bLines := strings.Split(b, "\n") | |
| 174 | + | // Use simple LCS-based diff. | |
| 175 | + | ops := diffLines(aLines, bLines) | |
| 176 | + | var sb strings.Builder | |
| 177 | + | fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name) | |
| 178 | + | for _, op := range ops { | |
| 179 | + | switch op.kind { | |
| 180 | + | case ' ': | |
| 181 | + | fmt.Fprintf(&sb, " %s\n", op.line) | |
| 182 | + | case '-': | |
| 183 | + | fmt.Fprintf(&sb, "-%s\n", op.line) | |
| 184 | + | case '+': | |
| 185 | + | fmt.Fprintf(&sb, "+%s\n", op.line) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return sb.String() | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | type diffOp struct { | |
| 192 | + | kind byte | |
| 193 | + | line string | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func diffLines(a, b []string) []diffOp { | |
| 197 | + | // LCS table. | |
| 198 | + | m, n := len(a), len(b) | |
| 199 | + | dp := make([][]int, m+1) | |
| 200 | + | for i := range dp { | |
| 201 | + | dp[i] = make([]int, n+1) | |
| 202 | + | } | |
| 203 | + | for i := 0; i < m; i++ { | |
| 204 | + | for j := 0; j < n; j++ { | |
| 205 | + | if a[i] == b[j] { | |
| 206 | + | dp[i+1][j+1] = dp[i][j] + 1 | |
| 207 | + | } else if dp[i+1][j] >= dp[i][j+1] { | |
| 208 | + | dp[i+1][j+1] = dp[i+1][j] | |
| 209 | + | } else { | |
| 210 | + | dp[i+1][j+1] = dp[i][j+1] | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | // Backtrack. | |
| 215 | + | var ops []diffOp | |
| 216 | + | i, j := m, n | |
| 217 | + | for i > 0 && j > 0 { | |
| 218 | + | if a[i-1] == b[j-1] { | |
| 219 | + | ops = append([]diffOp{{' ', a[i-1]}}, ops...) | |
| 220 | + | i-- | |
| 221 | + | j-- | |
| 222 | + | } else if dp[i][j-1] >= dp[i-1][j] { | |
| 223 | + | ops = append([]diffOp{{'+', b[j-1]}}, ops...) | |
| 224 | + | j-- | |
| 225 | + | } else { | |
| 226 | + | ops = append([]diffOp{{'-', a[i-1]}}, ops...) | |
| 227 | + | i-- | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | for i > 0 { | |
| 231 | + | ops = append([]diffOp{{'-', a[i-1]}}, ops...) | |
| 232 | + | i-- | |
| 233 | + | } | |
| 234 | + | for j > 0 { | |
| 235 | + | ops = append([]diffOp{{'+', b[j-1]}}, ops...) | |
| 236 | + | j-- | |
| 237 | + | } | |
| 238 | + | return ops | |
| 239 | + | } | |
A internal/agent/tools/read.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | // Package tools implements zot's built-in tools: read, write, edit, bash. | |
| 2 | + | package tools | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/internal/core" | |
| 14 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | maxReadLines = 2000 | |
| 19 | + | maxReadBytes = 50 * 1024 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // ReadTool reads file contents from disk. | |
| 23 | + | type ReadTool struct { | |
| 24 | + | CWD string | |
| 25 | + | Sandbox *Sandbox // when locked, confines reads to the sandbox root | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type readArgs struct { | |
| 29 | + | Path string `json:"path"` | |
| 30 | + | Offset int `json:"offset,omitempty"` | |
| 31 | + | Limit int `json:"limit,omitempty"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const readSchema = `{ | |
| 35 | + | "type":"object", | |
| 36 | + | "properties":{ | |
| 37 | + | "path":{"type":"string","description":"Path to the file to read (relative or absolute)"}, | |
| 38 | + | "offset":{"type":"integer","description":"Line number to start reading from (1-indexed)"}, | |
| 39 | + | "limit":{"type":"integer","description":"Maximum number of lines to read"} | |
| 40 | + | }, | |
| 41 | + | "required":["path"], | |
| 42 | + | "additionalProperties":false | |
| 43 | + | }` | |
| 44 | + | ||
| 45 | + | func (t *ReadTool) Name() string { return "read" } | |
| 46 | + | func (t *ReadTool) Description() string { | |
| 47 | + | return "Read the contents of a text file or an image (png/jpg/jpeg/gif/webp). Large files are truncated." | |
| 48 | + | } | |
| 49 | + | func (t *ReadTool) Schema() json.RawMessage { return json.RawMessage(readSchema) } | |
| 50 | + | ||
| 51 | + | func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 52 | + | var a readArgs | |
| 53 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 54 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 55 | + | } | |
| 56 | + | if a.Path == "" { | |
| 57 | + | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 58 | + | } | |
| 59 | + | path := resolvePath(t.CWD, a.Path) | |
| 60 | + | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 61 | + | return core.ToolResult{}, err | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | info, err := os.Stat(path) | |
| 65 | + | if err != nil { | |
| 66 | + | return core.ToolResult{}, err | |
| 67 | + | } | |
| 68 | + | if info.IsDir() { | |
| 69 | + | return core.ToolResult{}, fmt.Errorf("%s is a directory", path) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Image handling. | |
| 73 | + | if mime := imageMIME(path); mime != "" { | |
| 74 | + | data, err := os.ReadFile(path) | |
| 75 | + | if err != nil { | |
| 76 | + | return core.ToolResult{}, err | |
| 77 | + | } | |
| 78 | + | return core.ToolResult{ | |
| 79 | + | Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}}, | |
| 80 | + | }, nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | f, err := os.Open(path) | |
| 84 | + | if err != nil { | |
| 85 | + | return core.ToolResult{}, err | |
| 86 | + | } | |
| 87 | + | defer f.Close() | |
| 88 | + | ||
| 89 | + | // Read up to maxReadBytes and also line-limit. | |
| 90 | + | limited := io.LimitReader(f, int64(maxReadBytes)+1) | |
| 91 | + | data, err := io.ReadAll(limited) | |
| 92 | + | if err != nil { | |
| 93 | + | return core.ToolResult{}, err | |
| 94 | + | } | |
| 95 | + | truncBytes := len(data) > maxReadBytes | |
| 96 | + | if truncBytes { | |
| 97 | + | data = data[:maxReadBytes] | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | if looksBinary(data) { | |
| 101 | + | return core.ToolResult{}, fmt.Errorf("%s looks binary; refusing to read as text", a.Path) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | lines := strings.Split(string(data), "\n") | |
| 105 | + | // Trim trailing empty line from final \n. | |
| 106 | + | if n := len(lines); n > 0 && lines[n-1] == "" { | |
| 107 | + | lines = lines[:n-1] | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | start := 0 | |
| 111 | + | if a.Offset > 0 { | |
| 112 | + | start = a.Offset - 1 | |
| 113 | + | if start > len(lines) { | |
| 114 | + | start = len(lines) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | end := len(lines) | |
| 118 | + | if a.Limit > 0 && start+a.Limit < end { | |
| 119 | + | end = start + a.Limit | |
| 120 | + | } | |
| 121 | + | selected := lines[start:end] | |
| 122 | + | ||
| 123 | + | truncLines := false | |
| 124 | + | if len(selected) > maxReadLines { | |
| 125 | + | selected = selected[:maxReadLines] | |
| 126 | + | truncLines = true | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Render with 1-indexed line numbers, cat -n style. | |
| 130 | + | var sb strings.Builder | |
| 131 | + | for i, line := range selected { | |
| 132 | + | fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line) | |
| 133 | + | } | |
| 134 | + | if truncLines { | |
| 135 | + | sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines)) | |
| 136 | + | } | |
| 137 | + | if truncBytes { | |
| 138 | + | sb.WriteString(fmt.Sprintf("... [truncated at %d bytes]\n", maxReadBytes)) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | return core.ToolResult{ | |
| 142 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 143 | + | Details: map[string]any{ | |
| 144 | + | "path": path, | |
| 145 | + | "lines_truncated": truncLines, | |
| 146 | + | "bytes_truncated": truncBytes, | |
| 147 | + | "total_lines": len(lines), | |
| 148 | + | }, | |
| 149 | + | }, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func resolvePath(cwd, p string) string { | |
| 153 | + | if filepath.IsAbs(p) { | |
| 154 | + | return p | |
| 155 | + | } | |
| 156 | + | if cwd == "" { | |
| 157 | + | cwd, _ = os.Getwd() | |
| 158 | + | } | |
| 159 | + | return filepath.Join(cwd, p) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func imageMIME(path string) string { | |
| 163 | + | switch strings.ToLower(filepath.Ext(path)) { | |
| 164 | + | case ".png": | |
| 165 | + | return "image/png" | |
| 166 | + | case ".jpg", ".jpeg": | |
| 167 | + | return "image/jpeg" | |
| 168 | + | case ".gif": | |
| 169 | + | return "image/gif" | |
| 170 | + | case ".webp": | |
| 171 | + | return "image/webp" | |
| 172 | + | } | |
| 173 | + | return "" | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB. | |
| 177 | + | func looksBinary(b []byte) bool { | |
| 178 | + | n := len(b) | |
| 179 | + | if n > 8192 { | |
| 180 | + | n = 8192 | |
| 181 | + | } | |
| 182 | + | for i := 0; i < n; i++ { | |
| 183 | + | if b[i] == 0 { | |
| 184 | + | return true | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return false | |
| 188 | + | } | |
A internal/agent/tools/sandbox.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "sync/atomic" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Sandbox guards tool access to the filesystem and shell. When Locked | |
| 11 | + | // is true (1), file tools refuse paths outside Root and bash runs with | |
| 12 | + | // a restricted environment. | |
| 13 | + | // | |
| 14 | + | // The value is designed to be shared across tool instances (by pointer). | |
| 15 | + | // Enable/Disable are atomic so they can be toggled from the TUI. | |
| 16 | + | type Sandbox struct { | |
| 17 | + | Root string | |
| 18 | + | locked atomic.Bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewSandbox returns a Sandbox rooted at cwd. It starts unlocked. | |
| 22 | + | func NewSandbox(root string) *Sandbox { | |
| 23 | + | s := &Sandbox{Root: root} | |
| 24 | + | return s | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Lock enables sandboxing. | |
| 28 | + | func (s *Sandbox) Lock() { s.locked.Store(true) } | |
| 29 | + | ||
| 30 | + | // Unlock disables sandboxing. | |
| 31 | + | func (s *Sandbox) Unlock() { s.locked.Store(false) } | |
| 32 | + | ||
| 33 | + | // Locked reports whether the sandbox is enforcing limits. | |
| 34 | + | func (s *Sandbox) Locked() bool { return s != nil && s.locked.Load() } | |
| 35 | + | ||
| 36 | + | // CheckPath verifies that path resolves inside the sandbox root. | |
| 37 | + | // Returns an error describing the violation if not. No-op when unlocked. | |
| 38 | + | // Callers should pass an already-absolute path (use resolvePath() first). | |
| 39 | + | func (s *Sandbox) CheckPath(path string) error { | |
| 40 | + | if !s.Locked() { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | rootAbs, err := canonical(s.Root) | |
| 44 | + | if err != nil { | |
| 45 | + | return fmt.Errorf("sandbox root: %w", err) | |
| 46 | + | } | |
| 47 | + | // Resolve the target to an absolute path. Walk up until we find an | |
| 48 | + | // existing parent so symlinks inside nonexistent dirs are still caught. | |
| 49 | + | target, err := canonicalOrParent(path) | |
| 50 | + | if err != nil { | |
| 51 | + | return fmt.Errorf("sandbox path: %w", err) | |
| 52 | + | } | |
| 53 | + | if !isUnder(rootAbs, target) { | |
| 54 | + | return fmt.Errorf("locked: path %q is outside sandbox root %q (use /unlock to disable)", path, s.Root) | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // CheckCommand applies a lightweight sanity check to a bash command | |
| 60 | + | // when locked. We cannot fully sandbox a shell, but we can reject the | |
| 61 | + | // most obvious escapes so the model does not accidentally touch files | |
| 62 | + | // outside root via absolute paths. | |
| 63 | + | func (s *Sandbox) CheckCommand(cmd string) error { | |
| 64 | + | if !s.Locked() { | |
| 65 | + | return nil | |
| 66 | + | } | |
| 67 | + | cmd = strings.TrimSpace(cmd) | |
| 68 | + | if cmd == "" { | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | // Reject obvious destructive roots. | |
| 72 | + | banned := []string{ | |
| 73 | + | "rm -rf /", "rm -rf ~", "rm -rf $HOME", | |
| 74 | + | "sudo ", "su ", | |
| 75 | + | "chmod -R ", "chown -R ", | |
| 76 | + | "mkfs", "dd if=", "dd of=/", | |
| 77 | + | } | |
| 78 | + | lower := strings.ToLower(cmd) | |
| 79 | + | for _, b := range banned { | |
| 80 | + | if strings.Contains(lower, strings.ToLower(b)) { | |
| 81 | + | return fmt.Errorf("locked: command contains banned pattern %q (use /unlock to disable)", b) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | // Heuristic: reject a leading `cd /` or `cd ~` that tries to move | |
| 85 | + | // the shell out of the sandbox. Note this only catches simple cases; | |
| 86 | + | // a determined adversary can still escape. This is a speed bump for | |
| 87 | + | // the model, not a security boundary. | |
| 88 | + | first := strings.TrimSpace(strings.SplitN(cmd, ";", 2)[0]) | |
| 89 | + | first = strings.TrimSpace(strings.SplitN(first, "&&", 2)[0]) | |
| 90 | + | if strings.HasPrefix(first, "cd /") || strings.HasPrefix(first, "cd ~") || | |
| 91 | + | strings.HasPrefix(first, "cd $HOME") || strings.HasPrefix(first, "cd ..") { | |
| 92 | + | return fmt.Errorf("locked: cd outside sandbox root is not allowed (use /unlock to disable)") | |
| 93 | + | } | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // canonical returns an absolute, symlink-resolved path. Errors on missing files. | |
| 98 | + | func canonical(p string) (string, error) { | |
| 99 | + | abs, err := filepath.Abs(p) | |
| 100 | + | if err != nil { | |
| 101 | + | return "", err | |
| 102 | + | } | |
| 103 | + | return filepath.EvalSymlinks(abs) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // canonicalOrParent returns the canonical path for p; if p doesn't exist, | |
| 107 | + | // it walks up until it finds an existing directory, then appends the | |
| 108 | + | // remaining path components. This catches symlink-escapes in non-existent | |
| 109 | + | // subtrees (e.g. "new-file" inside a symlinked dir). | |
| 110 | + | func canonicalOrParent(p string) (string, error) { | |
| 111 | + | abs, err := filepath.Abs(p) | |
| 112 | + | if err != nil { | |
| 113 | + | return "", err | |
| 114 | + | } | |
| 115 | + | // If the full path exists, resolve it. | |
| 116 | + | if resolved, err := filepath.EvalSymlinks(abs); err == nil { | |
| 117 | + | return resolved, nil | |
| 118 | + | } | |
| 119 | + | // Otherwise, find the longest existing prefix. | |
| 120 | + | remaining := "" | |
| 121 | + | current := abs | |
| 122 | + | for { | |
| 123 | + | if resolved, err := filepath.EvalSymlinks(current); err == nil { | |
| 124 | + | return filepath.Join(resolved, remaining), nil | |
| 125 | + | } | |
| 126 | + | parent := filepath.Dir(current) | |
| 127 | + | if parent == current { | |
| 128 | + | return abs, nil | |
| 129 | + | } | |
| 130 | + | remaining = filepath.Join(filepath.Base(current), remaining) | |
| 131 | + | current = parent | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // isUnder reports whether target is equal to root or a descendant of it. | |
| 136 | + | func isUnder(root, target string) bool { | |
| 137 | + | rootSep := root | |
| 138 | + | if !strings.HasSuffix(rootSep, string(filepath.Separator)) { | |
| 139 | + | rootSep += string(filepath.Separator) | |
| 140 | + | } | |
| 141 | + | return target == root || strings.HasPrefix(target, rootSep) | |
| 142 | + | } | |
A internal/agent/tools/sandbox_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSandboxLockedBlocksOutside(t *testing.T) { | |
| 11 | + | root := t.TempDir() | |
| 12 | + | outside := t.TempDir() | |
| 13 | + | outsideFile := filepath.Join(outside, "a.txt") | |
| 14 | + | os.WriteFile(outsideFile, []byte("secret"), 0o644) | |
| 15 | + | ||
| 16 | + | sb := NewSandbox(root) | |
| 17 | + | sb.Lock() | |
| 18 | + | ||
| 19 | + | if err := sb.CheckPath(outsideFile); err == nil { | |
| 20 | + | t.Fatal("expected outside path to be blocked") | |
| 21 | + | } | |
| 22 | + | inside := filepath.Join(root, "ok.txt") | |
| 23 | + | if err := sb.CheckPath(inside); err != nil { | |
| 24 | + | t.Fatalf("inside path blocked unexpectedly: %v", err) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestSandboxUnlockedAllows(t *testing.T) { | |
| 29 | + | root := t.TempDir() | |
| 30 | + | outside := t.TempDir() | |
| 31 | + | sb := NewSandbox(root) | |
| 32 | + | if err := sb.CheckPath(filepath.Join(outside, "a.txt")); err != nil { | |
| 33 | + | t.Fatalf("unlocked should allow: %v", err) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestSandboxCommandBanned(t *testing.T) { | |
| 38 | + | sb := NewSandbox(t.TempDir()) | |
| 39 | + | sb.Lock() | |
| 40 | + | cases := []string{ | |
| 41 | + | "sudo apt-get install foo", | |
| 42 | + | "rm -rf /", | |
| 43 | + | "cd /etc && ls", | |
| 44 | + | "cd .. && rm foo", | |
| 45 | + | } | |
| 46 | + | for _, c := range cases { | |
| 47 | + | if err := sb.CheckCommand(c); err == nil { | |
| 48 | + | t.Fatalf("expected %q to be banned", c) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | // Allowed: | |
| 52 | + | for _, c := range []string{"ls", "go test ./...", "cd subdir && ls"} { | |
| 53 | + | if err := sb.CheckCommand(c); err != nil { | |
| 54 | + | t.Fatalf("expected %q to be allowed: %v", c, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestReadToolRejectsOutsideWhenLocked(t *testing.T) { | |
| 60 | + | root := t.TempDir() | |
| 61 | + | outside := t.TempDir() | |
| 62 | + | outsideFile := filepath.Join(outside, "a.txt") | |
| 63 | + | os.WriteFile(outsideFile, []byte("x"), 0o644) | |
| 64 | + | ||
| 65 | + | sb := NewSandbox(root) | |
| 66 | + | sb.Lock() | |
| 67 | + | tool := &ReadTool{CWD: root, Sandbox: sb} | |
| 68 | + | ||
| 69 | + | _, err := tool.Execute(context.Background(), | |
| 70 | + | mustJSONRaw(t, map[string]any{"path": outsideFile}), nil) | |
| 71 | + | if err == nil { | |
| 72 | + | t.Fatal("expected sandbox error") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func mustJSONRaw(t *testing.T, v any) []byte { | |
| 77 | + | t.Helper() | |
| 78 | + | return mustJSON(t, v) | |
| 79 | + | } | |
A internal/agent/tools/tools_test.go +184 -0
| @@ -0,0 +1,184 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func mustJSON(t *testing.T, v any) json.RawMessage { | |
| 16 | + | t.Helper() | |
| 17 | + | b, err := json.Marshal(v) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | return b | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestReadText(t *testing.T) { | |
| 25 | + | dir := t.TempDir() | |
| 26 | + | p := filepath.Join(dir, "a.txt") | |
| 27 | + | if err := os.WriteFile(p, []byte("hello\nworld\n"), 0o644); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | tool := &ReadTool{CWD: dir} | |
| 31 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt"}), nil) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 36 | + | if !strings.Contains(got, "hello") || !strings.Contains(got, "world") { | |
| 37 | + | t.Fatalf("got %q", got) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestReadOffsetLimit(t *testing.T) { | |
| 42 | + | dir := t.TempDir() | |
| 43 | + | p := filepath.Join(dir, "a.txt") | |
| 44 | + | os.WriteFile(p, []byte("1\n2\n3\n4\n5\n"), 0o644) | |
| 45 | + | tool := &ReadTool{CWD: dir} | |
| 46 | + | res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil) | |
| 47 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 48 | + | if !strings.Contains(got, "2\t2") || !strings.Contains(got, "3\t3") { | |
| 49 | + | t.Fatalf("got %q", got) | |
| 50 | + | } | |
| 51 | + | if strings.Contains(got, "1\t1") || strings.Contains(got, "4\t4") { | |
| 52 | + | t.Fatalf("leaked lines: %q", got) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestReadBinaryRejected(t *testing.T) { | |
| 57 | + | dir := t.TempDir() | |
| 58 | + | p := filepath.Join(dir, "b.bin") | |
| 59 | + | os.WriteFile(p, []byte{0x00, 0x01, 0x02}, 0o644) | |
| 60 | + | tool := &ReadTool{CWD: dir} | |
| 61 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "b.bin"}), nil); err == nil { | |
| 62 | + | t.Fatal("want binary rejection") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestWriteCreatesDirs(t *testing.T) { | |
| 67 | + | dir := t.TempDir() | |
| 68 | + | tool := &WriteTool{CWD: dir} | |
| 69 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "sub/a.txt", "content": "hi"}), nil) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | b, err := os.ReadFile(filepath.Join(dir, "sub", "a.txt")) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | if string(b) != "hi" { | |
| 78 | + | t.Fatalf("got %q", string(b)) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestEditSingle(t *testing.T) { | |
| 83 | + | dir := t.TempDir() | |
| 84 | + | p := filepath.Join(dir, "a.txt") | |
| 85 | + | os.WriteFile(p, []byte("hello world\n"), 0o644) | |
| 86 | + | tool := &EditTool{CWD: dir} | |
| 87 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |
| 88 | + | "path": "a.txt", | |
| 89 | + | "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}}, | |
| 90 | + | }), nil) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | b, _ := os.ReadFile(p) | |
| 95 | + | if string(b) != "hello gopher\n" { | |
| 96 | + | t.Fatalf("got %q", string(b)) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestEditMultiple(t *testing.T) { | |
| 101 | + | dir := t.TempDir() | |
| 102 | + | p := filepath.Join(dir, "a.txt") | |
| 103 | + | os.WriteFile(p, []byte("a\nb\nc\n"), 0o644) | |
| 104 | + | tool := &EditTool{CWD: dir} | |
| 105 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |
| 106 | + | "path": "a.txt", | |
| 107 | + | "edits": []map[string]any{ | |
| 108 | + | {"oldText": "a", "newText": "A"}, | |
| 109 | + | {"oldText": "c", "newText": "C"}, | |
| 110 | + | }, | |
| 111 | + | }), nil) | |
| 112 | + | if err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | b, _ := os.ReadFile(p) | |
| 116 | + | if string(b) != "A\nb\nC\n" { | |
| 117 | + | t.Fatalf("got %q", string(b)) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestEditAmbiguous(t *testing.T) { | |
| 122 | + | dir := t.TempDir() | |
| 123 | + | p := filepath.Join(dir, "a.txt") | |
| 124 | + | os.WriteFile(p, []byte("x\nx\n"), 0o644) | |
| 125 | + | tool := &EditTool{CWD: dir} | |
| 126 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |
| 127 | + | "path": "a.txt", | |
| 128 | + | "edits": []map[string]any{{"oldText": "x", "newText": "y"}}, | |
| 129 | + | }), nil) | |
| 130 | + | if err == nil { | |
| 131 | + | t.Fatal("want ambiguous error") | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestEditPreservesCRLF(t *testing.T) { | |
| 136 | + | dir := t.TempDir() | |
| 137 | + | p := filepath.Join(dir, "a.txt") | |
| 138 | + | os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644) | |
| 139 | + | tool := &EditTool{CWD: dir} | |
| 140 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{ | |
| 141 | + | "path": "a.txt", | |
| 142 | + | "edits": []map[string]any{{"oldText": "world", "newText": "gopher"}}, | |
| 143 | + | }), nil) | |
| 144 | + | if err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | b, _ := os.ReadFile(p) | |
| 148 | + | if string(b) != "hello\r\ngopher\r\n" { | |
| 149 | + | t.Fatalf("got %q", string(b)) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestBashSuccess(t *testing.T) { | |
| 154 | + | if runtime.GOOS == "windows" { | |
| 155 | + | t.Skip("posix shell only") | |
| 156 | + | } | |
| 157 | + | tool := &BashTool{CWD: t.TempDir()} | |
| 158 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi"}), nil) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 163 | + | if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") { | |
| 164 | + | t.Fatalf("got %q", got) | |
| 165 | + | } | |
| 166 | + | if res.IsError { | |
| 167 | + | t.Fatal("unexpected error flag") | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestBashFailure(t *testing.T) { | |
| 172 | + | if runtime.GOOS == "windows" { | |
| 173 | + | t.Skip("posix shell only") | |
| 174 | + | } | |
| 175 | + | tool := &BashTool{CWD: t.TempDir()} | |
| 176 | + | res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false"}), nil) | |
| 177 | + | if !res.IsError { | |
| 178 | + | t.Fatal("want error") | |
| 179 | + | } | |
| 180 | + | got := res.Content[0].(provider.TextBlock).Text | |
| 181 | + | if !strings.Contains(got, "[exit 1]") { | |
| 182 | + | t.Fatalf("got %q", got) | |
| 183 | + | } | |
| 184 | + | } | |
A internal/agent/tools/write.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/internal/core" | |
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // WriteTool writes content to a file, creating parent directories. | |
| 15 | + | type WriteTool struct { | |
| 16 | + | CWD string | |
| 17 | + | Sandbox *Sandbox | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type writeArgs struct { | |
| 21 | + | Path string `json:"path"` | |
| 22 | + | Content string `json:"content"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | const writeSchema = `{ | |
| 26 | + | "type":"object", | |
| 27 | + | "properties":{ | |
| 28 | + | "path":{"type":"string","description":"Path to the file to write (relative or absolute)"}, | |
| 29 | + | "content":{"type":"string","description":"File contents. Overwrites any existing file."} | |
| 30 | + | }, | |
| 31 | + | "required":["path","content"], | |
| 32 | + | "additionalProperties":false | |
| 33 | + | }` | |
| 34 | + | ||
| 35 | + | func (t *WriteTool) Name() string { return "write" } | |
| 36 | + | func (t *WriteTool) Description() string { | |
| 37 | + | return "Write content to a file. Creates parent directories. Overwrites existing files." | |
| 38 | + | } | |
| 39 | + | func (t *WriteTool) Schema() json.RawMessage { return json.RawMessage(writeSchema) } | |
| 40 | + | ||
| 41 | + | func (t *WriteTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 42 | + | var a writeArgs | |
| 43 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 44 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 45 | + | } | |
| 46 | + | if a.Path == "" { | |
| 47 | + | return core.ToolResult{}, fmt.Errorf("path is required") | |
| 48 | + | } | |
| 49 | + | path := resolvePath(t.CWD, a.Path) | |
| 50 | + | if err := t.Sandbox.CheckPath(path); err != nil { | |
| 51 | + | return core.ToolResult{}, err | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 55 | + | return core.ToolResult{}, err | |
| 56 | + | } | |
| 57 | + | if err := os.WriteFile(path, []byte(a.Content), 0o644); err != nil { | |
| 58 | + | return core.ToolResult{}, err | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | msg := fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path) | |
| 62 | + | return core.ToolResult{ | |
| 63 | + | Content: []provider.Content{provider.TextBlock{Text: msg}}, | |
| 64 | + | Details: map[string]any{"path": path, "bytes": len(a.Content)}, | |
| 65 | + | }, nil | |
| 66 | + | } | |
A internal/auth/auth_test.go +273 -0
| @@ -0,0 +1,273 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "net/http" | |
| 7 | + | "net/url" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestStoreRoundTrip(t *testing.T) { | |
| 15 | + | dir := t.TempDir() | |
| 16 | + | s := NewStore(filepath.Join(dir, "auth.json")) | |
| 17 | + | ||
| 18 | + | if err := s.SetAPIKey("anthropic", "sk-ant-xyz"); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | c, err := s.Load() | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | if c.Anthropic.APIKey != "sk-ant-xyz" { | |
| 26 | + | t.Fatalf("got %+v", c) | |
| 27 | + | } | |
| 28 | + | if !c.Has("anthropic") || c.Method("anthropic") != "apikey" { | |
| 29 | + | t.Fatalf("method mismatch: %q", c.Method("anthropic")) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | tok := OAuthToken{AccessToken: "tok", RefreshToken: "ref", Expiry: time.Now().Add(1 * time.Hour)} | |
| 33 | + | if err := s.SetOAuth("openai", tok); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | c, _ = s.Load() | |
| 37 | + | if c.OpenAI.OAuth == nil || c.OpenAI.OAuth.AccessToken != "tok" { | |
| 38 | + | t.Fatalf("got %+v", c.OpenAI) | |
| 39 | + | } | |
| 40 | + | if c.Method("openai") != "oauth" { | |
| 41 | + | t.Fatalf("method=%q", c.Method("openai")) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | if err := s.Clear("anthropic"); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | c, _ = s.Load() | |
| 48 | + | if c.Has("anthropic") { | |
| 49 | + | t.Fatalf("expected cleared, got %+v", c.Anthropic) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestPKCE(t *testing.T) { | |
| 54 | + | p, err := NewPKCE() | |
| 55 | + | if err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | if len(p.Verifier) < 40 || len(p.Challenge) < 40 { | |
| 59 | + | t.Fatalf("short pkce: %+v", p) | |
| 60 | + | } | |
| 61 | + | if p.Verifier == p.Challenge { | |
| 62 | + | t.Fatal("verifier == challenge") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestAuthorizeURL(t *testing.T) { | |
| 67 | + | p, _ := NewPKCE() | |
| 68 | + | u, state, err := AnthropicOAuth.AuthorizeURL(p) | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | parsed, err := url.Parse(u) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | q := parsed.Query() | |
| 77 | + | if q.Get("client_id") != AnthropicOAuth.ClientID { | |
| 78 | + | t.Fatal("missing client_id") | |
| 79 | + | } | |
| 80 | + | if q.Get("code_challenge_method") != "S256" { | |
| 81 | + | t.Fatal("wrong challenge method") | |
| 82 | + | } | |
| 83 | + | // anthropic uses state == verifier | |
| 84 | + | if q.Get("state") != p.Verifier { | |
| 85 | + | t.Fatalf("state mismatch: %q vs verifier %q", q.Get("state"), p.Verifier) | |
| 86 | + | } | |
| 87 | + | if state != p.Verifier { | |
| 88 | + | t.Fatalf("returned state != verifier") | |
| 89 | + | } | |
| 90 | + | if q.Get("redirect_uri") != AnthropicOAuth.RedirectURI() { | |
| 91 | + | t.Fatalf("redirect_uri=%q", q.Get("redirect_uri")) | |
| 92 | + | } | |
| 93 | + | if q.Get("code") != "true" { | |
| 94 | + | t.Fatalf("missing code=true extra arg") | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestOpenAIAuthorizeURL(t *testing.T) { | |
| 99 | + | p, _ := NewPKCE() | |
| 100 | + | u, state, err := OpenAIOAuth.AuthorizeURL(p) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | parsed, _ := url.Parse(u) | |
| 105 | + | q := parsed.Query() | |
| 106 | + | if q.Get("redirect_uri") != "http://localhost:1455/auth/callback" { | |
| 107 | + | t.Fatalf("redirect_uri=%q", q.Get("redirect_uri")) | |
| 108 | + | } | |
| 109 | + | if state == p.Verifier { | |
| 110 | + | t.Fatal("openai state should be random, not verifier") | |
| 111 | + | } | |
| 112 | + | if q.Get("originator") != "zot" { | |
| 113 | + | t.Fatalf("originator=%q", q.Get("originator")) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestServerIndexAndAPIKey(t *testing.T) { | |
| 118 | + | s, err := NewServer() | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | defer s.Shutdown(context.Background()) | |
| 123 | + | ||
| 124 | + | // Swap the probe out for a fake that always succeeds. | |
| 125 | + | s.probeFn = func(ctx context.Context, provider, key string) error { return nil } | |
| 126 | + | ||
| 127 | + | resp, err := http.Get(s.URL() + "/") | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | defer resp.Body.Close() | |
| 132 | + | if resp.StatusCode != 200 { | |
| 133 | + | t.Fatalf("index status %d", resp.StatusCode) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | resp, err = http.Get(s.URL() + "/apikey?provider=anthropic") | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | resp.Body.Close() | |
| 141 | + | if resp.StatusCode != 200 { | |
| 142 | + | t.Fatalf("apikey form status %d", resp.StatusCode) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // POST a fake key and expect a success result on the channel. | |
| 146 | + | _, err = http.PostForm(s.URL()+"/apikey", url.Values{ | |
| 147 | + | "provider": {"anthropic"}, | |
| 148 | + | "api_key": {"sk-ant-test"}, | |
| 149 | + | }) | |
| 150 | + | if err != nil { | |
| 151 | + | t.Fatal(err) | |
| 152 | + | } | |
| 153 | + | select { | |
| 154 | + | case r := <-s.Result(): | |
| 155 | + | if r.Err != nil { | |
| 156 | + | t.Fatalf("unexpected err: %v", r.Err) | |
| 157 | + | } | |
| 158 | + | if r.Provider != "anthropic" || r.APIKey != "sk-ant-test" || r.Method != "apikey" { | |
| 159 | + | t.Fatalf("bad result: %+v", r) | |
| 160 | + | } | |
| 161 | + | case <-time.After(2 * time.Second): | |
| 162 | + | t.Fatal("no result") | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestServerCallback(t *testing.T) { | |
| 167 | + | s, err := NewServer() | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | defer s.Shutdown(context.Background()) | |
| 172 | + | ||
| 173 | + | u := s.URL() + "/callback?code=abc123&state=anthropic:xyz" | |
| 174 | + | resp, err := http.Get(u) | |
| 175 | + | if err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | resp.Body.Close() | |
| 179 | + | ||
| 180 | + | select { | |
| 181 | + | case r := <-s.Result(): | |
| 182 | + | if r.Provider != "anthropic" || r.Code != "abc123" || r.State != "anthropic:xyz" || r.Method != "oauth" { | |
| 183 | + | t.Fatalf("bad result: %+v", r) | |
| 184 | + | } | |
| 185 | + | case <-time.After(2 * time.Second): | |
| 186 | + | t.Fatal("no result") | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func TestCallbackServerRoundTrip(t *testing.T) { | |
| 191 | + | // Use a provider config with an ephemeral port so tests stay hermetic. | |
| 192 | + | op := OAuthProvider{ | |
| 193 | + | Name: "anthropic", | |
| 194 | + | RedirectHost: "127.0.0.1", | |
| 195 | + | RedirectPort: 0, // let kernel pick | |
| 196 | + | RedirectPath: "/callback", | |
| 197 | + | } | |
| 198 | + | // We need a real fixed port for CallbackServer.NewCallbackServer; find one. | |
| 199 | + | l, err := net.Listen("tcp", "127.0.0.1:0") | |
| 200 | + | if err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | port := l.Addr().(*net.TCPAddr).Port | |
| 204 | + | l.Close() | |
| 205 | + | op.RedirectPort = port | |
| 206 | + | ||
| 207 | + | cs, err := NewCallbackServer(op, "thestate") | |
| 208 | + | if err != nil { | |
| 209 | + | t.Fatal(err) | |
| 210 | + | } | |
| 211 | + | defer cs.Shutdown() | |
| 212 | + | ||
| 213 | + | go func() { | |
| 214 | + | time.Sleep(50 * time.Millisecond) | |
| 215 | + | _, _ = http.Get(cs.URL() + "?code=abc&state=thestate") | |
| 216 | + | }() | |
| 217 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 218 | + | defer cancel() | |
| 219 | + | r, err := cs.Result(ctx) | |
| 220 | + | if err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | if r.Code != "abc" || r.State != "thestate" { | |
| 224 | + | t.Fatalf("bad result: %+v", r) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestCallbackServerStateMismatch(t *testing.T) { | |
| 229 | + | l, _ := net.Listen("tcp", "127.0.0.1:0") | |
| 230 | + | port := l.Addr().(*net.TCPAddr).Port | |
| 231 | + | l.Close() | |
| 232 | + | op := OAuthProvider{Name: "anthropic", RedirectHost: "127.0.0.1", RedirectPort: port, RedirectPath: "/callback"} | |
| 233 | + | cs, err := NewCallbackServer(op, "expected") | |
| 234 | + | if err != nil { | |
| 235 | + | t.Fatal(err) | |
| 236 | + | } | |
| 237 | + | defer cs.Shutdown() | |
| 238 | + | ||
| 239 | + | go func() { | |
| 240 | + | time.Sleep(50 * time.Millisecond) | |
| 241 | + | _, _ = http.Get(cs.URL() + "?code=abc&state=wrong") | |
| 242 | + | }() | |
| 243 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 244 | + | defer cancel() | |
| 245 | + | r, err := cs.Result(ctx) | |
| 246 | + | if err != nil { | |
| 247 | + | t.Fatal(err) | |
| 248 | + | } | |
| 249 | + | if r.Err == nil || !strings.Contains(r.Err.Error(), "state") { | |
| 250 | + | t.Fatalf("want state error, got %+v", r) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestServerCallbackError(t *testing.T) { | |
| 255 | + | s, err := NewServer() | |
| 256 | + | if err != nil { | |
| 257 | + | t.Fatal(err) | |
| 258 | + | } | |
| 259 | + | defer s.Shutdown(context.Background()) | |
| 260 | + | ||
| 261 | + | _, _ = http.Get(s.URL() + "/callback?error=access_denied&state=openai:xyz") | |
| 262 | + | select { | |
| 263 | + | case r := <-s.Result(): | |
| 264 | + | if r.Err == nil || r.Provider != "openai" { | |
| 265 | + | t.Fatalf("bad result: %+v", r) | |
| 266 | + | } | |
| 267 | + | if !strings.Contains(r.Err.Error(), "access_denied") { | |
| 268 | + | t.Fatalf("missing reason: %v", r.Err) | |
| 269 | + | } | |
| 270 | + | case <-time.After(2 * time.Second): | |
| 271 | + | t.Fatal("no result") | |
| 272 | + | } | |
| 273 | + | } | |
A internal/auth/callback.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // CallbackResult is what an OAuth callback server returns once the | |
| 14 | + | // browser hits the redirect URI. | |
| 15 | + | type CallbackResult struct { | |
| 16 | + | Code string | |
| 17 | + | State string | |
| 18 | + | Err error | |
| 19 | + | RawPath string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // CallbackServer is a single-shot OAuth callback listener bound to the | |
| 23 | + | // fixed port that the provider has whitelisted for its client. | |
| 24 | + | type CallbackServer struct { | |
| 25 | + | l net.Listener | |
| 26 | + | srv *http.Server | |
| 27 | + | provider OAuthProvider | |
| 28 | + | state string | |
| 29 | + | result chan CallbackResult | |
| 30 | + | once sync.Once | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewCallbackServer starts a listener on p.RedirectPort/p.RedirectPath. | |
| 34 | + | // Returns an error if that port is already in use (e.g. another login | |
| 35 | + | // flow already running, or the official CLI is running concurrently). | |
| 36 | + | func NewCallbackServer(p OAuthProvider, expectedState string) (*CallbackServer, error) { | |
| 37 | + | addr := fmt.Sprintf("%s:%d", p.RedirectHost, p.RedirectPort) | |
| 38 | + | l, err := net.Listen("tcp", addr) | |
| 39 | + | if err != nil { | |
| 40 | + | return nil, fmt.Errorf("bind %s: %w (is another zot/claude-code/codex login already running?)", addr, err) | |
| 41 | + | } | |
| 42 | + | cs := &CallbackServer{ | |
| 43 | + | l: l, | |
| 44 | + | provider: p, | |
| 45 | + | state: expectedState, | |
| 46 | + | result: make(chan CallbackResult, 1), | |
| 47 | + | } | |
| 48 | + | mux := http.NewServeMux() | |
| 49 | + | mux.HandleFunc(p.RedirectPath, cs.handle) | |
| 50 | + | cs.srv = &http.Server{ | |
| 51 | + | Handler: mux, | |
| 52 | + | ReadTimeout: 15 * time.Second, | |
| 53 | + | WriteTimeout: 15 * time.Second, | |
| 54 | + | } | |
| 55 | + | go func() { _ = cs.srv.Serve(l) }() | |
| 56 | + | return cs, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // URL returns the full redirect URI this server is listening on. | |
| 60 | + | func (cs *CallbackServer) URL() string { return cs.provider.RedirectURI() } | |
| 61 | + | ||
| 62 | + | // Result blocks until the callback is received or ctx is cancelled. | |
| 63 | + | func (cs *CallbackServer) Result(ctx context.Context) (CallbackResult, error) { | |
| 64 | + | select { | |
| 65 | + | case r := <-cs.result: | |
| 66 | + | return r, nil | |
| 67 | + | case <-ctx.Done(): | |
| 68 | + | return CallbackResult{}, ctx.Err() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Shutdown stops the server. Safe to call more than once. | |
| 73 | + | func (cs *CallbackServer) Shutdown() { | |
| 74 | + | cs.once.Do(func() { | |
| 75 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 76 | + | _ = cs.srv.Shutdown(ctx) | |
| 77 | + | cancel() | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (cs *CallbackServer) handle(w http.ResponseWriter, r *http.Request) { | |
| 82 | + | q := r.URL.Query() | |
| 83 | + | w.Header().Set("content-type", "text/html; charset=utf-8") | |
| 84 | + | if errParam := q.Get("error"); errParam != "" { | |
| 85 | + | msg := errParam | |
| 86 | + | if d := q.Get("error_description"); d != "" { | |
| 87 | + | msg += ": " + d | |
| 88 | + | } | |
| 89 | + | w.WriteHeader(http.StatusBadRequest) | |
| 90 | + | _, _ = w.Write([]byte(oauthErrorHTML(msg))) | |
| 91 | + | cs.deliver(CallbackResult{Err: fmt.Errorf(msg), RawPath: r.URL.RequestURI()}) | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | code := q.Get("code") | |
| 95 | + | state := q.Get("state") | |
| 96 | + | if code == "" { | |
| 97 | + | w.WriteHeader(http.StatusBadRequest) | |
| 98 | + | _, _ = w.Write([]byte(oauthErrorHTML("missing authorization code"))) | |
| 99 | + | cs.deliver(CallbackResult{Err: fmt.Errorf("missing code"), RawPath: r.URL.RequestURI()}) | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | if cs.state != "" && state != cs.state { | |
| 103 | + | w.WriteHeader(http.StatusBadRequest) | |
| 104 | + | _, _ = w.Write([]byte(oauthErrorHTML("state mismatch"))) | |
| 105 | + | cs.deliver(CallbackResult{Err: fmt.Errorf("state mismatch"), RawPath: r.URL.RequestURI()}) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | w.WriteHeader(http.StatusOK) | |
| 109 | + | _, _ = w.Write([]byte(oauthSuccessHTML(cs.provider.Name))) | |
| 110 | + | cs.deliver(CallbackResult{Code: code, State: state, RawPath: r.URL.RequestURI()}) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (cs *CallbackServer) deliver(r CallbackResult) { | |
| 114 | + | select { | |
| 115 | + | case cs.result <- r: | |
| 116 | + | default: | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // ---- static HTML for the callback tab ---- | |
| 121 | + | ||
| 122 | + | // All zot-served pages share a single monochrome style: black text on | |
| 123 | + | // white, thin horizontal rules, monospace type. Matches the tui aesthetic. | |
| 124 | + | const monoStyle = `<style> | |
| 125 | + | :root { color-scheme: light; } | |
| 126 | + | * { box-sizing: border-box; } | |
| 127 | + | body { | |
| 128 | + | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| 129 | + | background: #ffffff; | |
| 130 | + | color: #000000; | |
| 131 | + | max-width: 44rem; | |
| 132 | + | margin: 0 auto; | |
| 133 | + | padding: 3rem 1.5rem; | |
| 134 | + | line-height: 1.55; | |
| 135 | + | } | |
| 136 | + | h1 { | |
| 137 | + | font-size: 1rem; | |
| 138 | + | font-weight: 600; | |
| 139 | + | margin: 0 0 0.25rem; | |
| 140 | + | letter-spacing: 0.01em; | |
| 141 | + | } | |
| 142 | + | h1 .mark { display: inline-block; width: 1.25rem; } | |
| 143 | + | .rule { border: 0; border-top: 1px solid #000000; margin: 1.5rem 0; } | |
| 144 | + | .muted { color: #595959; } | |
| 145 | + | .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; } | |
| 146 | + | .msg { padding: 0.75rem 0; } | |
| 147 | + | a { color: inherit; } | |
| 148 | + | </style>` | |
| 149 | + | ||
| 150 | + | func oauthSuccessHTML(provider string) string { | |
| 151 | + | p := strings.ToLower(provider) | |
| 152 | + | return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body> | |
| 153 | + | <h1><span class="mark">✓</span> logged in to ` + p + `</h1> | |
| 154 | + | <hr class="rule"> | |
| 155 | + | <p class="msg">zot received the callback. you can close this tab.</p> | |
| 156 | + | </body></html>` | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func oauthErrorHTML(msg string) string { | |
| 160 | + | return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body> | |
| 161 | + | <h1><span class="mark">✗</span> login failed</h1> | |
| 162 | + | <hr class="rule"> | |
| 163 | + | <p class="msg mono">` + htmlEscape(msg) + `</p> | |
| 164 | + | <p class="muted">go back to zot and try again.</p> | |
| 165 | + | </body></html>` | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func htmlEscape(s string) string { | |
| 169 | + | r := strings.NewReplacer("&", "&", "<", "<", ">", ">", "\"", """) | |
| 170 | + | return r.Replace(s) | |
| 171 | + | } | |
A internal/auth/manager.go +281 -0
| @@ -0,0 +1,281 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os/exec" | |
| 7 | + | "runtime" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Event is delivered on Manager.Events(). | |
| 13 | + | type Event struct { | |
| 14 | + | Kind string // "started" | "browser_open" | "success" | "error" | "canceled" | |
| 15 | + | Provider string | |
| 16 | + | Method string | |
| 17 | + | URL string // login URL (for "started"/"browser_open") | |
| 18 | + | Message string // on error | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Manager drives a login flow end-to-end. It owns a local web server | |
| 22 | + | // (for api-key form) plus provider-specific OAuth callback servers. | |
| 23 | + | type Manager struct { | |
| 24 | + | store *Store | |
| 25 | + | keyServer *Server // random-port web form server (api-key flow) | |
| 26 | + | oauthServer *CallbackServer // fixed-port callback server (oauth flow, only one at a time) | |
| 27 | + | mu sync.Mutex | |
| 28 | + | events chan Event | |
| 29 | + | openBrowser bool | |
| 30 | + | ||
| 31 | + | oauthCtx context.Context | |
| 32 | + | oauthCancel context.CancelFunc | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // NewManager returns a Manager bound to store. | |
| 36 | + | func NewManager(store *Store) *Manager { | |
| 37 | + | return &Manager{ | |
| 38 | + | store: store, | |
| 39 | + | events: make(chan Event, 16), | |
| 40 | + | openBrowser: true, | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Store returns the underlying credential store. | |
| 45 | + | func (m *Manager) Store() *Store { return m.store } | |
| 46 | + | ||
| 47 | + | // Events returns the read-only event channel. | |
| 48 | + | func (m *Manager) Events() <-chan Event { return m.events } | |
| 49 | + | ||
| 50 | + | // Close shuts down any running servers and cancels pending flows. | |
| 51 | + | func (m *Manager) Close() { | |
| 52 | + | m.mu.Lock() | |
| 53 | + | defer m.mu.Unlock() | |
| 54 | + | if m.keyServer != nil { | |
| 55 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 56 | + | _ = m.keyServer.Shutdown(ctx) | |
| 57 | + | cancel() | |
| 58 | + | m.keyServer = nil | |
| 59 | + | } | |
| 60 | + | if m.oauthServer != nil { | |
| 61 | + | m.oauthServer.Shutdown() | |
| 62 | + | m.oauthServer = nil | |
| 63 | + | } | |
| 64 | + | if m.oauthCancel != nil { | |
| 65 | + | m.oauthCancel() | |
| 66 | + | m.oauthCancel = nil | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // ---- API key flow ---- | |
| 71 | + | ||
| 72 | + | // StartAPIKey launches the API-key login flow. | |
| 73 | + | func (m *Manager) StartAPIKey(provider string) (string, error) { | |
| 74 | + | if provider != "anthropic" && provider != "openai" { | |
| 75 | + | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 76 | + | } | |
| 77 | + | if err := m.ensureKeyServer(); err != nil { | |
| 78 | + | return "", err | |
| 79 | + | } | |
| 80 | + | u := m.keyServer.URL() + "/apikey?provider=" + provider | |
| 81 | + | go m.maybeOpen(u) | |
| 82 | + | m.emit(Event{Kind: "started", Provider: provider, Method: "apikey", URL: u}) | |
| 83 | + | return u, nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (m *Manager) ensureKeyServer() error { | |
| 87 | + | m.mu.Lock() | |
| 88 | + | defer m.mu.Unlock() | |
| 89 | + | if m.keyServer != nil { | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | s, err := NewServer() | |
| 93 | + | if err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | m.keyServer = s | |
| 97 | + | go m.consumeKeyServerResults() | |
| 98 | + | return nil | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (m *Manager) consumeKeyServerResults() { | |
| 102 | + | for res := range m.keyServer.Result() { | |
| 103 | + | if res.Err != nil { | |
| 104 | + | m.emit(Event{Kind: "error", Provider: res.Provider, Method: res.Method, Message: res.Err.Error()}) | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | if err := m.store.SetAPIKey(res.Provider, res.APIKey); err != nil { | |
| 108 | + | m.emit(Event{Kind: "error", Provider: res.Provider, Method: "apikey", Message: err.Error()}) | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | m.emit(Event{Kind: "success", Provider: res.Provider, Method: "apikey"}) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // ---- OAuth flow ---- | |
| 116 | + | ||
| 117 | + | // StartOAuth launches the subscription OAuth flow for provider. | |
| 118 | + | // Only one oauth flow may be in progress at a time (because the | |
| 119 | + | // callback port is fixed per provider and re-used by the official CLIs). | |
| 120 | + | func (m *Manager) StartOAuth(provider string) (string, error) { | |
| 121 | + | var op OAuthProvider | |
| 122 | + | switch provider { | |
| 123 | + | case "anthropic": | |
| 124 | + | op = AnthropicOAuth | |
| 125 | + | case "openai": | |
| 126 | + | op = OpenAIOAuth | |
| 127 | + | default: | |
| 128 | + | return "", fmt.Errorf("provider must be anthropic or openai") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | m.mu.Lock() | |
| 132 | + | if m.oauthServer != nil { | |
| 133 | + | m.oauthServer.Shutdown() | |
| 134 | + | m.oauthServer = nil | |
| 135 | + | } | |
| 136 | + | if m.oauthCancel != nil { | |
| 137 | + | m.oauthCancel() | |
| 138 | + | } | |
| 139 | + | m.mu.Unlock() | |
| 140 | + | ||
| 141 | + | pkce, err := NewPKCE() | |
| 142 | + | if err != nil { | |
| 143 | + | return "", err | |
| 144 | + | } | |
| 145 | + | authURL, state, err := op.AuthorizeURL(pkce) | |
| 146 | + | if err != nil { | |
| 147 | + | return "", err | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | cs, err := NewCallbackServer(op, state) | |
| 151 | + | if err != nil { | |
| 152 | + | return "", err | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 156 | + | m.mu.Lock() | |
| 157 | + | m.oauthServer = cs | |
| 158 | + | m.oauthCtx = ctx | |
| 159 | + | m.oauthCancel = cancel | |
| 160 | + | m.mu.Unlock() | |
| 161 | + | ||
| 162 | + | go m.awaitOAuth(ctx, op, cs, pkce, state) | |
| 163 | + | go m.maybeOpen(authURL) | |
| 164 | + | m.emit(Event{Kind: "started", Provider: provider, Method: "oauth", URL: authURL}) | |
| 165 | + | return authURL, nil | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, cs *CallbackServer, pkce PKCE, state string) { | |
| 169 | + | defer cs.Shutdown() | |
| 170 | + | ||
| 171 | + | waitCtx, waitCancel := context.WithTimeout(ctx, 10*time.Minute) | |
| 172 | + | defer waitCancel() | |
| 173 | + | res, err := cs.Result(waitCtx) | |
| 174 | + | if err != nil { | |
| 175 | + | if ctx.Err() == nil { | |
| 176 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: "timeout waiting for callback"}) | |
| 177 | + | } | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | if res.Err != nil { | |
| 181 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: res.Err.Error()}) | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 186 | + | defer exCancel() | |
| 187 | + | tok, err := op.Exchange(exCtx, res.Code, res.State, pkce) | |
| 188 | + | if err != nil { | |
| 189 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 190 | + | return | |
| 191 | + | } | |
| 192 | + | if err := m.store.SetOAuth(op.Name, *tok); err != nil { | |
| 193 | + | m.emit(Event{Kind: "error", Provider: op.Name, Method: "oauth", Message: err.Error()}) | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | m.emit(Event{Kind: "success", Provider: op.Name, Method: "oauth"}) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // CancelOAuth aborts any in-flight OAuth flow. | |
| 200 | + | func (m *Manager) CancelOAuth() { | |
| 201 | + | m.mu.Lock() | |
| 202 | + | defer m.mu.Unlock() | |
| 203 | + | if m.oauthCancel != nil { | |
| 204 | + | m.oauthCancel() | |
| 205 | + | m.oauthCancel = nil | |
| 206 | + | } | |
| 207 | + | if m.oauthServer != nil { | |
| 208 | + | m.oauthServer.Shutdown() | |
| 209 | + | m.oauthServer = nil | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // ---- shared ---- | |
| 214 | + | ||
| 215 | + | func (m *Manager) emit(e Event) { | |
| 216 | + | select { | |
| 217 | + | case m.events <- e: | |
| 218 | + | default: | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // maybeOpen tries to open u in the system browser. | |
| 223 | + | func (m *Manager) maybeOpen(u string) { | |
| 224 | + | if !m.openBrowser { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | var cmd *exec.Cmd | |
| 228 | + | switch runtime.GOOS { | |
| 229 | + | case "darwin": | |
| 230 | + | cmd = exec.Command("open", u) | |
| 231 | + | case "windows": | |
| 232 | + | cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u) | |
| 233 | + | default: | |
| 234 | + | cmd = exec.Command("xdg-open", u) | |
| 235 | + | } | |
| 236 | + | _ = cmd.Start() | |
| 237 | + | m.emit(Event{Kind: "browser_open", URL: u}) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // RefreshIfNeeded returns the currently-usable credential for provider, | |
| 241 | + | // refreshing an expired OAuth access token if necessary. | |
| 242 | + | func (m *Manager) RefreshIfNeeded(ctx context.Context, provider string) (string, string, error) { | |
| 243 | + | creds, err := m.store.Load() | |
| 244 | + | if err != nil { | |
| 245 | + | return "", "", err | |
| 246 | + | } | |
| 247 | + | p := creds.get(provider) | |
| 248 | + | if p == nil { | |
| 249 | + | return "", "", fmt.Errorf("unknown provider %q", provider) | |
| 250 | + | } | |
| 251 | + | if p.APIKey != "" { | |
| 252 | + | return p.APIKey, "apikey", nil | |
| 253 | + | } | |
| 254 | + | if p.OAuth == nil { | |
| 255 | + | return "", "", fmt.Errorf("no credentials for %s", provider) | |
| 256 | + | } | |
| 257 | + | if !p.OAuth.Expired() { | |
| 258 | + | return p.OAuth.AccessToken, "oauth", nil | |
| 259 | + | } | |
| 260 | + | if p.OAuth.RefreshToken == "" { | |
| 261 | + | return "", "", fmt.Errorf("%s access token expired and no refresh token is available; please /login again", provider) | |
| 262 | + | } | |
| 263 | + | var op OAuthProvider | |
| 264 | + | switch provider { | |
| 265 | + | case "anthropic": | |
| 266 | + | op = AnthropicOAuth | |
| 267 | + | case "openai": | |
| 268 | + | op = OpenAIOAuth | |
| 269 | + | } | |
| 270 | + | tok, err := op.Refresh(ctx, p.OAuth.RefreshToken) | |
| 271 | + | if err != nil { | |
| 272 | + | return "", "", err | |
| 273 | + | } | |
| 274 | + | if tok.RefreshToken == "" { | |
| 275 | + | tok.RefreshToken = p.OAuth.RefreshToken | |
| 276 | + | } | |
| 277 | + | if err := m.store.SetOAuth(provider, *tok); err != nil { | |
| 278 | + | return "", "", err | |
| 279 | + | } | |
| 280 | + | return tok.AccessToken, "oauth", nil | |
| 281 | + | } | |
A internal/auth/oauth.go +270 -0
| @@ -0,0 +1,270 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "encoding/base64" | |
| 8 | + | "encoding/hex" | |
| 9 | + | "encoding/json" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "net/http" | |
| 13 | + | "net/url" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // OAuthProvider configures a single subscription OAuth flow. | |
| 19 | + | // | |
| 20 | + | // WARNING: the client ids used here belong to the official first-party | |
| 21 | + | // CLIs (Claude Code for Anthropic, Codex CLI for OpenAI). Using them from | |
| 22 | + | // third-party software is against the respective terms of service and | |
| 23 | + | // may be revoked at any time. zot exposes this only behind | |
| 24 | + | // --experimental-oauth. | |
| 25 | + | type OAuthProvider struct { | |
| 26 | + | Name string | |
| 27 | + | AuthURL string | |
| 28 | + | TokenURL string | |
| 29 | + | ClientID string | |
| 30 | + | Scopes []string | |
| 31 | + | RedirectHost string // usually "localhost" | |
| 32 | + | RedirectPort int // must match the client registration at the provider | |
| 33 | + | RedirectPath string // e.g. "/callback" or "/auth/callback" | |
| 34 | + | ExtraAuthArgs map[string]string | |
| 35 | + | ExtraTokenArgs map[string]string | |
| 36 | + | // TokenBodyJSON selects JSON body vs application/x-www-form-urlencoded | |
| 37 | + | // for token requests. Anthropic requires JSON; OpenAI requires form. | |
| 38 | + | TokenBodyJSON bool | |
| 39 | + | // StateEqualsVerifier: if true, the OAuth state parameter is set to the | |
| 40 | + | // PKCE verifier instead of a random value (Anthropic's Claude Code does this). | |
| 41 | + | StateEqualsVerifier bool | |
| 42 | + | // IncludeStateInTokenRequest: if true, the state value is included in | |
| 43 | + | // the token-exchange payload (Anthropic requires this; OpenAI does not). | |
| 44 | + | IncludeStateInTokenRequest bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // RedirectURI returns the full redirect URI for this provider. | |
| 48 | + | func (p OAuthProvider) RedirectURI() string { | |
| 49 | + | return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Built-in providers. Values mirror Anthropic's Claude Code CLI and | |
| 53 | + | // OpenAI's Codex CLI. Changing any of these fields will most likely | |
| 54 | + | // cause the flow to fail with redirect_uri_mismatch or invalid_client. | |
| 55 | + | var ( | |
| 56 | + | // Anthropic Claude Pro/Max: used by the Claude Code CLI. | |
| 57 | + | AnthropicOAuth = OAuthProvider{ | |
| 58 | + | Name: "anthropic", | |
| 59 | + | AuthURL: "https://claude.ai/oauth/authorize", | |
| 60 | + | TokenURL: "https://platform.claude.com/v1/oauth/token", | |
| 61 | + | ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", | |
| 62 | + | Scopes: []string{"org:create_api_key", "user:profile", "user:inference"}, | |
| 63 | + | RedirectHost: "localhost", | |
| 64 | + | RedirectPort: 53692, | |
| 65 | + | RedirectPath: "/callback", | |
| 66 | + | ExtraAuthArgs: map[string]string{ | |
| 67 | + | "code": "true", | |
| 68 | + | }, | |
| 69 | + | TokenBodyJSON: true, | |
| 70 | + | StateEqualsVerifier: true, | |
| 71 | + | IncludeStateInTokenRequest: true, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // OpenAI ChatGPT subscription: used by Codex CLI. | |
| 75 | + | OpenAIOAuth = OAuthProvider{ | |
| 76 | + | Name: "openai", | |
| 77 | + | AuthURL: "https://auth.openai.com/oauth/authorize", | |
| 78 | + | TokenURL: "https://auth.openai.com/oauth/token", | |
| 79 | + | ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", | |
| 80 | + | Scopes: []string{"openid", "profile", "email", "offline_access"}, | |
| 81 | + | RedirectHost: "localhost", | |
| 82 | + | RedirectPort: 1455, | |
| 83 | + | RedirectPath: "/auth/callback", | |
| 84 | + | ExtraAuthArgs: map[string]string{ | |
| 85 | + | "id_token_add_organizations": "true", | |
| 86 | + | "codex_cli_simplified_flow": "true", | |
| 87 | + | "originator": "zot", | |
| 88 | + | }, | |
| 89 | + | } | |
| 90 | + | ) | |
| 91 | + | ||
| 92 | + | // PKCE holds a verifier/challenge pair. | |
| 93 | + | type PKCE struct { | |
| 94 | + | Verifier string | |
| 95 | + | Challenge string | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // NewPKCE generates a random PKCE pair using SHA-256. | |
| 99 | + | func NewPKCE() (PKCE, error) { | |
| 100 | + | buf := make([]byte, 32) | |
| 101 | + | if _, err := rand.Read(buf); err != nil { | |
| 102 | + | return PKCE{}, err | |
| 103 | + | } | |
| 104 | + | verifier := base64.RawURLEncoding.EncodeToString(buf) | |
| 105 | + | sum := sha256.Sum256([]byte(verifier)) | |
| 106 | + | challenge := base64.RawURLEncoding.EncodeToString(sum[:]) | |
| 107 | + | return PKCE{Verifier: verifier, Challenge: challenge}, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // RandomHex returns a random hex string, used as an OAuth state value. | |
| 111 | + | func RandomHex(n int) (string, error) { | |
| 112 | + | buf := make([]byte, n) | |
| 113 | + | if _, err := rand.Read(buf); err != nil { | |
| 114 | + | return "", err | |
| 115 | + | } | |
| 116 | + | return hex.EncodeToString(buf), nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // AuthorizeURL builds the browser URL for the initial authorization | |
| 120 | + | // request. The returned state value is what we expect to see on the | |
| 121 | + | // callback and what the caller should persist until that arrives. | |
| 122 | + | func (p OAuthProvider) AuthorizeURL(pkce PKCE) (authURL, state string, err error) { | |
| 123 | + | if p.StateEqualsVerifier { | |
| 124 | + | state = pkce.Verifier | |
| 125 | + | } else { | |
| 126 | + | state, err = RandomHex(16) | |
| 127 | + | if err != nil { | |
| 128 | + | return "", "", err | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | q := url.Values{} | |
| 132 | + | q.Set("response_type", "code") | |
| 133 | + | q.Set("client_id", p.ClientID) | |
| 134 | + | q.Set("redirect_uri", p.RedirectURI()) | |
| 135 | + | q.Set("scope", strings.Join(p.Scopes, " ")) | |
| 136 | + | q.Set("code_challenge", pkce.Challenge) | |
| 137 | + | q.Set("code_challenge_method", "S256") | |
| 138 | + | q.Set("state", state) | |
| 139 | + | for k, v := range p.ExtraAuthArgs { | |
| 140 | + | q.Set(k, v) | |
| 141 | + | } | |
| 142 | + | return p.AuthURL + "?" + q.Encode(), state, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Exchange swaps an authorization code for an OAuthToken. | |
| 146 | + | func (p OAuthProvider) Exchange(ctx context.Context, code, state string, pkce PKCE) (*OAuthToken, error) { | |
| 147 | + | payload := map[string]string{ | |
| 148 | + | "grant_type": "authorization_code", | |
| 149 | + | "client_id": p.ClientID, | |
| 150 | + | "code": code, | |
| 151 | + | "redirect_uri": p.RedirectURI(), | |
| 152 | + | "code_verifier": pkce.Verifier, | |
| 153 | + | } | |
| 154 | + | if p.IncludeStateInTokenRequest { | |
| 155 | + | payload["state"] = state | |
| 156 | + | } | |
| 157 | + | for k, v := range p.ExtraTokenArgs { | |
| 158 | + | payload[k] = v | |
| 159 | + | } | |
| 160 | + | return p.doTokenRequest(ctx, payload) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Refresh uses a refresh_token to obtain a new access token. | |
| 164 | + | func (p OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*OAuthToken, error) { | |
| 165 | + | payload := map[string]string{ | |
| 166 | + | "grant_type": "refresh_token", | |
| 167 | + | "client_id": p.ClientID, | |
| 168 | + | "refresh_token": refreshToken, | |
| 169 | + | } | |
| 170 | + | for k, v := range p.ExtraTokenArgs { | |
| 171 | + | payload[k] = v | |
| 172 | + | } | |
| 173 | + | return p.doTokenRequest(ctx, payload) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // ExtractOpenAIAccountID parses the chatgpt_account_id claim from an | |
| 177 | + | // OpenAI id_token JWT. The claim sits under "https://api.openai.com/auth" | |
| 178 | + | // in the JWT payload. Returns "" if the token is malformed or lacks | |
| 179 | + | // the claim. | |
| 180 | + | func ExtractOpenAIAccountID(idToken string) string { | |
| 181 | + | parts := strings.Split(idToken, ".") | |
| 182 | + | if len(parts) < 2 { | |
| 183 | + | return "" | |
| 184 | + | } | |
| 185 | + | payload, err := base64.RawURLEncoding.DecodeString(parts[1]) | |
| 186 | + | if err != nil { | |
| 187 | + | // Some tokens use standard base64 with padding. | |
| 188 | + | payload, err = base64.StdEncoding.DecodeString(parts[1]) | |
| 189 | + | if err != nil { | |
| 190 | + | return "" | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | var body map[string]interface{} | |
| 194 | + | if err := json.Unmarshal(payload, &body); err != nil { | |
| 195 | + | return "" | |
| 196 | + | } | |
| 197 | + | authClaim, _ := body["https://api.openai.com/auth"].(map[string]interface{}) | |
| 198 | + | if authClaim == nil { | |
| 199 | + | return "" | |
| 200 | + | } | |
| 201 | + | id, _ := authClaim["chatgpt_account_id"].(string) | |
| 202 | + | return id | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (p OAuthProvider) doTokenRequest(ctx context.Context, payload map[string]string) (*OAuthToken, error) { | |
| 206 | + | var ( | |
| 207 | + | req *http.Request | |
| 208 | + | err error | |
| 209 | + | contentType string | |
| 210 | + | ) | |
| 211 | + | if p.TokenBodyJSON { | |
| 212 | + | body, _ := json.Marshal(payload) | |
| 213 | + | req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(string(body))) | |
| 214 | + | contentType = "application/json" | |
| 215 | + | } else { | |
| 216 | + | form := url.Values{} | |
| 217 | + | for k, v := range payload { | |
| 218 | + | form.Set(k, v) | |
| 219 | + | } | |
| 220 | + | req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(form.Encode())) | |
| 221 | + | contentType = "application/x-www-form-urlencoded" | |
| 222 | + | } | |
| 223 | + | if err != nil { | |
| 224 | + | return nil, err | |
| 225 | + | } | |
| 226 | + | req.Header.Set("content-type", contentType) | |
| 227 | + | req.Header.Set("accept", "application/json") | |
| 228 | + | ||
| 229 | + | client := &http.Client{Timeout: 30 * time.Second} | |
| 230 | + | resp, err := client.Do(req) | |
| 231 | + | if err != nil { | |
| 232 | + | return nil, fmt.Errorf("token request: %w", err) | |
| 233 | + | } | |
| 234 | + | defer resp.Body.Close() | |
| 235 | + | respBody, _ := io.ReadAll(resp.Body) | |
| 236 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 237 | + | return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) | |
| 238 | + | } | |
| 239 | + | var raw struct { | |
| 240 | + | AccessToken string `json:"access_token"` | |
| 241 | + | RefreshToken string `json:"refresh_token"` | |
| 242 | + | TokenType string `json:"token_type"` | |
| 243 | + | ExpiresIn int `json:"expires_in"` | |
| 244 | + | Scope string `json:"scope"` | |
| 245 | + | IDToken string `json:"id_token"` | |
| 246 | + | } | |
| 247 | + | if err := json.Unmarshal(respBody, &raw); err != nil { | |
| 248 | + | return nil, fmt.Errorf("parse token response: %w: %s", err, string(respBody)) | |
| 249 | + | } | |
| 250 | + | if raw.AccessToken == "" { | |
| 251 | + | return nil, fmt.Errorf("empty access token in response: %s", string(respBody)) | |
| 252 | + | } | |
| 253 | + | tok := &OAuthToken{ | |
| 254 | + | AccessToken: raw.AccessToken, | |
| 255 | + | RefreshToken: raw.RefreshToken, | |
| 256 | + | TokenType: raw.TokenType, | |
| 257 | + | Scope: raw.Scope, | |
| 258 | + | ClientID: p.ClientID, | |
| 259 | + | } | |
| 260 | + | if raw.ExpiresIn > 0 { | |
| 261 | + | tok.Expiry = time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second) | |
| 262 | + | } | |
| 263 | + | // Capture id_token & account id for providers that use OIDC (OpenAI). | |
| 264 | + | // This lets the OpenAI Codex provider send chatgpt-account-id. | |
| 265 | + | if raw.IDToken != "" { | |
| 266 | + | tok.IDToken = raw.IDToken | |
| 267 | + | tok.AccountID = ExtractOpenAIAccountID(raw.IDToken) | |
| 268 | + | } | |
| 269 | + | return tok, nil | |
| 270 | + | } | |
A internal/auth/probe.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ProbeAPIKey verifies that key is valid for provider by making a | |
| 11 | + | // lightweight authenticated request. Returns nil on success. | |
| 12 | + | func ProbeAPIKey(ctx context.Context, provider, key string) error { | |
| 13 | + | if key == "" { | |
| 14 | + | return fmt.Errorf("empty key") | |
| 15 | + | } | |
| 16 | + | c := &http.Client{Timeout: 15 * time.Second} | |
| 17 | + | var req *http.Request | |
| 18 | + | var err error | |
| 19 | + | ||
| 20 | + | switch provider { | |
| 21 | + | case "anthropic": | |
| 22 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil) | |
| 23 | + | if err != nil { | |
| 24 | + | return err | |
| 25 | + | } | |
| 26 | + | req.Header.Set("x-api-key", key) | |
| 27 | + | req.Header.Set("anthropic-version", "2023-06-01") | |
| 28 | + | case "openai": | |
| 29 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.openai.com/v1/models", nil) | |
| 30 | + | if err != nil { | |
| 31 | + | return err | |
| 32 | + | } | |
| 33 | + | req.Header.Set("authorization", "Bearer "+key) | |
| 34 | + | default: | |
| 35 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | resp, err := c.Do(req) | |
| 39 | + | if err != nil { | |
| 40 | + | return fmt.Errorf("probe %s: %w", provider, err) | |
| 41 | + | } | |
| 42 | + | defer resp.Body.Close() | |
| 43 | + | if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { | |
| 44 | + | return fmt.Errorf("%s rejected the key (http %d)", provider, resp.StatusCode) | |
| 45 | + | } | |
| 46 | + | if resp.StatusCode >= 400 { | |
| 47 | + | return fmt.Errorf("%s http %d", provider, resp.StatusCode) | |
| 48 | + | } | |
| 49 | + | return nil | |
| 50 | + | } | |
A internal/auth/server.go +275 -0
| @@ -0,0 +1,275 @@ | |||
| 1 | + | package auth | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "html/template" | |
| 7 | + | "net" | |
| 8 | + | "net/http" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // LoginResult is delivered on the channel returned by Server.Result(). | |
| 16 | + | type LoginResult struct { | |
| 17 | + | Provider string | |
| 18 | + | Method string // "apikey" | "oauth" | |
| 19 | + | APIKey string // populated when Method == "apikey" | |
| 20 | + | Code string // populated when Method == "oauth" | |
| 21 | + | State string // OAuth state (caller should verify) | |
| 22 | + | Err error | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Server is a tiny local HTTP server used by the login flows. It binds | |
| 26 | + | // to 127.0.0.1 on a random free port and serves: | |
| 27 | + | // | |
| 28 | + | // GET / landing page (menu) | |
| 29 | + | // GET /apikey?provider=... API key form | |
| 30 | + | // POST /apikey form submit -> probes -> stores via caller | |
| 31 | + | // GET /callback OAuth callback (query: code, state) | |
| 32 | + | // GET /success generic success page | |
| 33 | + | // GET /error generic error page | |
| 34 | + | // | |
| 35 | + | // The caller receives login events on Result(). The server stays up | |
| 36 | + | // until Shutdown() is called. | |
| 37 | + | type Server struct { | |
| 38 | + | l net.Listener | |
| 39 | + | srv *http.Server | |
| 40 | + | baseURL string | |
| 41 | + | results chan LoginResult | |
| 42 | + | probeFn func(ctx context.Context, provider, key string) error | |
| 43 | + | mu sync.Mutex | |
| 44 | + | shutdown bool | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // NewServer starts a new login server on a random free port bound to loopback. | |
| 48 | + | func NewServer() (*Server, error) { | |
| 49 | + | l, err := net.Listen("tcp", "127.0.0.1:0") | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | s := &Server{ | |
| 54 | + | l: l, | |
| 55 | + | baseURL: "http://" + l.Addr().String(), | |
| 56 | + | results: make(chan LoginResult, 4), | |
| 57 | + | probeFn: ProbeAPIKey, | |
| 58 | + | } | |
| 59 | + | mux := http.NewServeMux() | |
| 60 | + | mux.HandleFunc("/", s.handleIndex) | |
| 61 | + | mux.HandleFunc("/apikey", s.handleAPIKey) | |
| 62 | + | mux.HandleFunc("/callback", s.handleCallback) | |
| 63 | + | mux.HandleFunc("/success", s.handleSuccess) | |
| 64 | + | mux.HandleFunc("/error", s.handleError) | |
| 65 | + | s.srv = &http.Server{ | |
| 66 | + | Handler: mux, | |
| 67 | + | ReadTimeout: 15 * time.Second, | |
| 68 | + | WriteTimeout: 15 * time.Second, | |
| 69 | + | } | |
| 70 | + | go func() { _ = s.srv.Serve(l) }() | |
| 71 | + | return s, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // URL returns the base URL the server is listening on. | |
| 75 | + | func (s *Server) URL() string { return s.baseURL } | |
| 76 | + | ||
| 77 | + | // Port returns the TCP port the server is bound to. | |
| 78 | + | func (s *Server) Port() int { | |
| 79 | + | return s.l.Addr().(*net.TCPAddr).Port | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Result returns the channel receiving LoginResult events. | |
| 83 | + | func (s *Server) Result() <-chan LoginResult { return s.results } | |
| 84 | + | ||
| 85 | + | // Shutdown stops the server. It is safe to call multiple times. | |
| 86 | + | func (s *Server) Shutdown(ctx context.Context) error { | |
| 87 | + | s.mu.Lock() | |
| 88 | + | if s.shutdown { | |
| 89 | + | s.mu.Unlock() | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | s.shutdown = true | |
| 93 | + | s.mu.Unlock() | |
| 94 | + | return s.srv.Shutdown(ctx) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // ---- handlers ---- | |
| 98 | + | ||
| 99 | + | func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { | |
| 100 | + | if r.URL.Path != "/" { | |
| 101 | + | http.NotFound(w, r) | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | tpl.ExecuteTemplate(w, "index", map[string]any{ | |
| 105 | + | "Port": s.Port(), | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { | |
| 110 | + | switch r.Method { | |
| 111 | + | case http.MethodGet: | |
| 112 | + | provider := r.URL.Query().Get("provider") | |
| 113 | + | if provider != "anthropic" && provider != "openai" { | |
| 114 | + | http.Error(w, "provider must be anthropic or openai", http.StatusBadRequest) | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) | |
| 118 | + | case http.MethodPost: | |
| 119 | + | if err := r.ParseForm(); err != nil { | |
| 120 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | provider := strings.TrimSpace(r.FormValue("provider")) | |
| 124 | + | key := strings.TrimSpace(r.FormValue("api_key")) | |
| 125 | + | if provider == "" || key == "" { | |
| 126 | + | s.errorPage(w, "missing provider or api key") | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) | |
| 130 | + | defer cancel() | |
| 131 | + | if err := s.probeFn(ctx, provider, key); err != nil { | |
| 132 | + | s.errorPage(w, err.Error()) | |
| 133 | + | s.results <- LoginResult{Provider: provider, Method: "apikey", Err: err} | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | s.successPage(w, provider, "api key") | |
| 137 | + | s.results <- LoginResult{Provider: provider, Method: "apikey", APIKey: key} | |
| 138 | + | default: | |
| 139 | + | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { | |
| 144 | + | q := r.URL.Query() | |
| 145 | + | provider := q.Get("provider") | |
| 146 | + | if provider == "" { | |
| 147 | + | // Some OAuth providers don't echo back custom params; try state-encoded form. | |
| 148 | + | provider = decodeStateProvider(q.Get("state")) | |
| 149 | + | } | |
| 150 | + | if errParam := q.Get("error"); errParam != "" { | |
| 151 | + | msg := errParam | |
| 152 | + | if d := q.Get("error_description"); d != "" { | |
| 153 | + | msg += ": " + d | |
| 154 | + | } | |
| 155 | + | s.errorPage(w, msg) | |
| 156 | + | s.results <- LoginResult{Provider: provider, Method: "oauth", Err: fmt.Errorf(msg)} | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | code := q.Get("code") | |
| 160 | + | state := q.Get("state") | |
| 161 | + | if code == "" { | |
| 162 | + | s.errorPage(w, "missing authorization code") | |
| 163 | + | s.results <- LoginResult{Provider: provider, Method: "oauth", Err: fmt.Errorf("missing code")} | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | s.successPage(w, provider, "subscription") | |
| 167 | + | s.results <- LoginResult{Provider: provider, Method: "oauth", Code: code, State: state} | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (s *Server) handleSuccess(w http.ResponseWriter, r *http.Request) { | |
| 171 | + | q := r.URL.Query() | |
| 172 | + | s.successPage(w, q.Get("provider"), q.Get("method")) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (s *Server) handleError(w http.ResponseWriter, r *http.Request) { | |
| 176 | + | s.errorPage(w, r.URL.Query().Get("message")) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (s *Server) successPage(w http.ResponseWriter, provider, method string) { | |
| 180 | + | w.Header().Set("content-type", "text/html; charset=utf-8") | |
| 181 | + | tpl.ExecuteTemplate(w, "success", map[string]any{ | |
| 182 | + | "Provider": provider, | |
| 183 | + | "Method": method, | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (s *Server) errorPage(w http.ResponseWriter, msg string) { | |
| 188 | + | w.Header().Set("content-type", "text/html; charset=utf-8") | |
| 189 | + | w.WriteHeader(http.StatusBadRequest) | |
| 190 | + | tpl.ExecuteTemplate(w, "error", map[string]any{"Message": msg}) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // decodeStateProvider extracts the provider from a state string of the | |
| 194 | + | // form "<provider>:<nonce>". | |
| 195 | + | func decodeStateProvider(state string) string { | |
| 196 | + | if i := strings.IndexByte(state, ':'); i > 0 { | |
| 197 | + | return state[:i] | |
| 198 | + | } | |
| 199 | + | return "" | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // BuildRedirectURI returns the callback URL the OAuth server should | |
| 203 | + | // redirect to. | |
| 204 | + | func (s *Server) BuildRedirectURI() string { | |
| 205 | + | return s.baseURL + "/callback" | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Redirect sends an HTTP redirect to u. Used by the TUI to tell the | |
| 209 | + | // browser to bounce through our local server. Not currently used; kept | |
| 210 | + | // for future flows. | |
| 211 | + | func Redirect(w http.ResponseWriter, r *http.Request, u *url.URL) { | |
| 212 | + | http.Redirect(w, r, u.String(), http.StatusFound) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // ---- templates ---- | |
| 216 | + | // | |
| 217 | + | // All pages share the monochrome monoStyle defined in callback.go so | |
| 218 | + | // the browser tab looks like the tui: black on white, monospace, thin | |
| 219 | + | // rules, no rounded boxes, no color. | |
| 220 | + | ||
| 221 | + | var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `</head><body> | |
| 222 | + | <h1>zot login</h1> | |
| 223 | + | <hr class="rule"> | |
| 224 | + | <p>paste an api key for anthropic or openai. zot probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p> | |
| 225 | + | <p> | |
| 226 | + | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | |
| 227 | + | <a href="/apikey?provider=openai">openai api key →</a> | |
| 228 | + | </p> | |
| 229 | + | <hr class="rule"> | |
| 230 | + | <p class="muted">for a subscription login (claude pro/max · chatgpt plus/pro), close this tab and run /login inside zot.</p> | |
| 231 | + | </body></html>`)) | |
| 232 | + | ||
| 233 | + | func init() { | |
| 234 | + | template.Must(tpl.New("apikey").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `<style> | |
| 235 | + | form { display: flex; flex-direction: column; gap: 0.75rem; } | |
| 236 | + | label { font-size: 0.875rem; } | |
| 237 | + | input[type=password] { | |
| 238 | + | width: 100%; padding: 0.5rem 0.6rem; | |
| 239 | + | border: 1px solid #000000; background: #ffffff; color: #000000; | |
| 240 | + | font-family: inherit; font-size: 0.95rem; | |
| 241 | + | } | |
| 242 | + | button { | |
| 243 | + | align-self: flex-start; | |
| 244 | + | padding: 0.5rem 1.25rem; | |
| 245 | + | background: #000000; color: #ffffff; | |
| 246 | + | border: 1px solid #000000; font-family: inherit; font-size: 0.95rem; | |
| 247 | + | cursor: pointer; | |
| 248 | + | } | |
| 249 | + | button:hover { background: #ffffff; color: #000000; } | |
| 250 | + | </style></head><body> | |
| 251 | + | <h1>zot login · {{.Provider}} api key</h1> | |
| 252 | + | <hr class="rule"> | |
| 253 | + | <p>paste your {{.Provider}} api key. zot will probe the provider with it once, then save it if the key is accepted.</p> | |
| 254 | + | <form method="POST" action="/apikey"> | |
| 255 | + | <input type="hidden" name="provider" value="{{.Provider}}" /> | |
| 256 | + | <label for="api_key">api key</label> | |
| 257 | + | <input id="api_key" name="api_key" type="password" autocomplete="off" autofocus /> | |
| 258 | + | <button type="submit">log in</button> | |
| 259 | + | </form> | |
| 260 | + | </body></html>`)) | |
| 261 | + | ||
| 262 | + | template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body> | |
| 263 | + | <h1><span class="mark">✓</span> logged in to {{.Provider}}</h1> | |
| 264 | + | <hr class="rule"> | |
| 265 | + | <p class="msg">method: {{.Method}}</p> | |
| 266 | + | <p class="muted">zot received the callback. you can close this tab and return to the terminal.</p> | |
| 267 | + | </body></html>`)) | |
| 268 | + | ||
| 269 | + | template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body> | |
| 270 | + | <h1><span class="mark">✗</span> login failed</h1> | |
| 271 | + | <hr class="rule"> | |
| 272 | + | <p class="msg mono">{{.Message}}</p> | |
| 273 | + | <p class="muted">go back to zot and try again.</p> | |
| 274 | + | </body></html>`)) | |
| 275 | + | } | |
A internal/auth/store.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | // Package auth handles credential storage and the two login flows | |
| 2 | + | // supported by zot: API key and (experimental) subscription OAuth. | |
| 3 | + | // | |
| 4 | + | // All credentials live in $ZOT_HOME/auth.json (mode 0600). | |
| 5 | + | package auth | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "sync" | |
| 14 | + | "time" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Credentials is the on-disk schema. | |
| 18 | + | type Credentials struct { | |
| 19 | + | Anthropic ProviderCreds `json:"anthropic,omitempty"` | |
| 20 | + | OpenAI ProviderCreds `json:"openai,omitempty"` | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // ProviderCreds holds credentials for a single provider. Only one of | |
| 24 | + | // APIKey or OAuth is populated at a time. | |
| 25 | + | type ProviderCreds struct { | |
| 26 | + | APIKey string `json:"api_key,omitempty"` | |
| 27 | + | OAuth *OAuthToken `json:"oauth,omitempty"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // OAuthToken is an OAuth 2 token set with refresh support. | |
| 31 | + | type OAuthToken struct { | |
| 32 | + | AccessToken string `json:"access_token"` | |
| 33 | + | RefreshToken string `json:"refresh_token,omitempty"` | |
| 34 | + | TokenType string `json:"token_type,omitempty"` | |
| 35 | + | Expiry time.Time `json:"expiry,omitempty"` | |
| 36 | + | Scope string `json:"scope,omitempty"` | |
| 37 | + | // ClientID that issued this token (informational). | |
| 38 | + | ClientID string `json:"client_id,omitempty"` | |
| 39 | + | // IDToken is the OIDC id_token (provider-specific; currently only | |
| 40 | + | // used by the OpenAI Codex flow to derive the ChatGPT account id). | |
| 41 | + | IDToken string `json:"id_token,omitempty"` | |
| 42 | + | // AccountID is the ChatGPT account id extracted from IDToken, used | |
| 43 | + | // as the `chatgpt-account-id` header when calling chatgpt.com/backend-api. | |
| 44 | + | AccountID string `json:"account_id,omitempty"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Expired reports whether the token has passed its expiry (with a 60s | |
| 48 | + | // safety margin). Zero expiry is treated as non-expiring. | |
| 49 | + | func (t *OAuthToken) Expired() bool { | |
| 50 | + | if t == nil || t.Expiry.IsZero() { | |
| 51 | + | return false | |
| 52 | + | } | |
| 53 | + | return time.Now().After(t.Expiry.Add(-60 * time.Second)) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Has reports whether at least one credential is present for provider. | |
| 57 | + | func (c *Credentials) Has(provider string) bool { | |
| 58 | + | p := c.get(provider) | |
| 59 | + | return p != nil && (p.APIKey != "" || p.OAuth != nil) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Method returns "apikey", "oauth", or "" for the given provider. | |
| 63 | + | func (c *Credentials) Method(provider string) string { | |
| 64 | + | p := c.get(provider) | |
| 65 | + | if p == nil { | |
| 66 | + | return "" | |
| 67 | + | } | |
| 68 | + | if p.APIKey != "" { | |
| 69 | + | return "apikey" | |
| 70 | + | } | |
| 71 | + | if p.OAuth != nil { | |
| 72 | + | return "oauth" | |
| 73 | + | } | |
| 74 | + | return "" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (c *Credentials) get(provider string) *ProviderCreds { | |
| 78 | + | switch provider { | |
| 79 | + | case "anthropic": | |
| 80 | + | return &c.Anthropic | |
| 81 | + | case "openai": | |
| 82 | + | return &c.OpenAI | |
| 83 | + | } | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Store is a mutex-guarded read/write handle to the auth file. | |
| 88 | + | type Store struct { | |
| 89 | + | path string | |
| 90 | + | mu sync.Mutex | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // NewStore returns a Store bound to path. | |
| 94 | + | func NewStore(path string) *Store { return &Store{path: path} } | |
| 95 | + | ||
| 96 | + | // Load reads the current credentials. Returns a zero Credentials if the | |
| 97 | + | // file does not exist. | |
| 98 | + | func (s *Store) Load() (Credentials, error) { | |
| 99 | + | s.mu.Lock() | |
| 100 | + | defer s.mu.Unlock() | |
| 101 | + | return s.loadLocked() | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (s *Store) loadLocked() (Credentials, error) { | |
| 105 | + | var c Credentials | |
| 106 | + | b, err := os.ReadFile(s.path) | |
| 107 | + | if errors.Is(err, os.ErrNotExist) { | |
| 108 | + | return c, nil | |
| 109 | + | } | |
| 110 | + | if err != nil { | |
| 111 | + | return c, err | |
| 112 | + | } | |
| 113 | + | if err := json.Unmarshal(b, &c); err != nil { | |
| 114 | + | return c, fmt.Errorf("parse %s: %w", s.path, err) | |
| 115 | + | } | |
| 116 | + | return c, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // SetAPIKey replaces the API key for provider and saves to disk. | |
| 120 | + | func (s *Store) SetAPIKey(provider, key string) error { | |
| 121 | + | s.mu.Lock() | |
| 122 | + | defer s.mu.Unlock() | |
| 123 | + | c, err := s.loadLocked() | |
| 124 | + | if err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | p := c.get(provider) | |
| 128 | + | if p == nil { | |
| 129 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 130 | + | } | |
| 131 | + | p.APIKey = key | |
| 132 | + | p.OAuth = nil | |
| 133 | + | return s.saveLocked(c) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // SetOAuth replaces the OAuth token for provider and saves to disk. | |
| 137 | + | func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |
| 138 | + | s.mu.Lock() | |
| 139 | + | defer s.mu.Unlock() | |
| 140 | + | c, err := s.loadLocked() | |
| 141 | + | if err != nil { | |
| 142 | + | return err | |
| 143 | + | } | |
| 144 | + | p := c.get(provider) | |
| 145 | + | if p == nil { | |
| 146 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 147 | + | } | |
| 148 | + | p.APIKey = "" | |
| 149 | + | p.OAuth = &tok | |
| 150 | + | return s.saveLocked(c) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Clear removes all credentials for provider. | |
| 154 | + | func (s *Store) Clear(provider string) error { | |
| 155 | + | s.mu.Lock() | |
| 156 | + | defer s.mu.Unlock() | |
| 157 | + | c, err := s.loadLocked() | |
| 158 | + | if err != nil { | |
| 159 | + | return err | |
| 160 | + | } | |
| 161 | + | p := c.get(provider) | |
| 162 | + | if p == nil { | |
| 163 | + | return fmt.Errorf("unknown provider %q", provider) | |
| 164 | + | } | |
| 165 | + | *p = ProviderCreds{} | |
| 166 | + | return s.saveLocked(c) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (s *Store) saveLocked(c Credentials) error { | |
| 170 | + | if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | b, err := json.MarshalIndent(c, "", " ") | |
| 174 | + | if err != nil { | |
| 175 | + | return err | |
| 176 | + | } | |
| 177 | + | // Write atomically: write temp then rename. | |
| 178 | + | tmp := s.path + ".tmp" | |
| 179 | + | if err := os.WriteFile(tmp, b, 0o600); err != nil { | |
| 180 | + | return err | |
| 181 | + | } | |
| 182 | + | return os.Rename(tmp, s.path) | |
| 183 | + | } | |
A internal/core/agent.go +266 -0
| @@ -0,0 +1,266 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Agent is a stateful conversation bound to a provider client, a model, | |
| 15 | + | // and a set of tools. | |
| 16 | + | type Agent struct { | |
| 17 | + | Client provider.Client | |
| 18 | + | Model string | |
| 19 | + | System string | |
| 20 | + | Tools Registry | |
| 21 | + | MaxSteps int | |
| 22 | + | Reasoning string | |
| 23 | + | ||
| 24 | + | mu sync.Mutex | |
| 25 | + | messages []provider.Message | |
| 26 | + | cost CostTracker | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewAgent returns an Agent with sensible defaults. | |
| 30 | + | func NewAgent(client provider.Client, model, system string, tools Registry) *Agent { | |
| 31 | + | return &Agent{ | |
| 32 | + | Client: client, | |
| 33 | + | Model: model, | |
| 34 | + | System: system, | |
| 35 | + | Tools: tools, | |
| 36 | + | MaxSteps: 50, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Messages returns a copy of the current transcript. | |
| 41 | + | func (a *Agent) Messages() []provider.Message { | |
| 42 | + | a.mu.Lock() | |
| 43 | + | defer a.mu.Unlock() | |
| 44 | + | out := make([]provider.Message, len(a.messages)) | |
| 45 | + | copy(out, a.messages) | |
| 46 | + | return out | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // SetMessages replaces the transcript (used when resuming a session). | |
| 50 | + | func (a *Agent) SetMessages(msgs []provider.Message) { | |
| 51 | + | a.mu.Lock() | |
| 52 | + | defer a.mu.Unlock() | |
| 53 | + | a.messages = append(a.messages[:0], msgs...) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Cost returns the cumulative usage. | |
| 57 | + | func (a *Agent) Cost() provider.Usage { | |
| 58 | + | a.mu.Lock() | |
| 59 | + | defer a.mu.Unlock() | |
| 60 | + | return a.cost.Total | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Prompt sends a user message and runs the agent loop until the model | |
| 64 | + | // stops or an error occurs. Events are delivered via sink in order. | |
| 65 | + | // sink must not block the caller for long; buffer as needed. | |
| 66 | + | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | |
| 67 | + | if sink == nil { | |
| 68 | + | sink = func(AgentEvent) {} | |
| 69 | + | } | |
| 70 | + | content := []provider.Content{} | |
| 71 | + | if text != "" { | |
| 72 | + | content = append(content, provider.TextBlock{Text: text}) | |
| 73 | + | } | |
| 74 | + | for _, img := range images { | |
| 75 | + | content = append(content, img) | |
| 76 | + | } | |
| 77 | + | user := provider.Message{Role: provider.RoleUser, Content: content, Time: time.Now()} | |
| 78 | + | ||
| 79 | + | a.mu.Lock() | |
| 80 | + | a.messages = append(a.messages, user) | |
| 81 | + | a.mu.Unlock() | |
| 82 | + | sink(EvUserMessage{Message: user}) | |
| 83 | + | ||
| 84 | + | return a.runLoop(ctx, sink) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Continue runs the agent loop against the existing transcript. Used | |
| 88 | + | // after appending tool results manually or to retry. | |
| 89 | + | func (a *Agent) Continue(ctx context.Context, sink func(AgentEvent)) error { | |
| 90 | + | if sink == nil { | |
| 91 | + | sink = func(AgentEvent) {} | |
| 92 | + | } | |
| 93 | + | return a.runLoop(ctx, sink) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error { | |
| 97 | + | for step := 1; step <= a.MaxSteps; step++ { | |
| 98 | + | sink(EvTurnStart{Step: step}) | |
| 99 | + | stop, assistantMsg, err := a.oneTurn(ctx, sink) | |
| 100 | + | sink(EvTurnEnd{Stop: stop, Err: err}) | |
| 101 | + | if err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | if stop == provider.StopToolUse { | |
| 106 | + | // Execute each tool call, append a single tool-results message, continue. | |
| 107 | + | toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink) | |
| 108 | + | a.mu.Lock() | |
| 109 | + | a.messages = append(a.messages, toolMsg) | |
| 110 | + | a.mu.Unlock() | |
| 111 | + | // If context was cancelled during tool execution, bail out. | |
| 112 | + | if err := ctx.Err(); err != nil { | |
| 113 | + | sink(EvDone{}) | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | _ = hadError | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Terminal stop (end, length, error, aborted). | |
| 121 | + | sink(EvDone{}) | |
| 122 | + | return nil | |
| 123 | + | } | |
| 124 | + | sink(EvDone{}) | |
| 125 | + | return fmt.Errorf("max steps (%d) exceeded", a.MaxSteps) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // oneTurn calls the LLM once, forwards events, returns the stop reason | |
| 129 | + | // and the assembled assistant message (already appended to the transcript). | |
| 130 | + | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | |
| 131 | + | req := provider.Request{ | |
| 132 | + | Model: a.Model, | |
| 133 | + | System: a.System, | |
| 134 | + | Messages: a.Messages(), | |
| 135 | + | Tools: a.Tools.Specs(), | |
| 136 | + | Reasoning: a.Reasoning, | |
| 137 | + | } | |
| 138 | + | stream, err := a.Client.Stream(ctx, req) | |
| 139 | + | if err != nil { | |
| 140 | + | return provider.StopError, provider.Message{}, err | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | sink(EvAssistantStart{}) | |
| 144 | + | ||
| 145 | + | var ( | |
| 146 | + | stop provider.StopReason | |
| 147 | + | finalErr error | |
| 148 | + | finalMsg provider.Message | |
| 149 | + | ) | |
| 150 | + | ||
| 151 | + | for ev := range stream { | |
| 152 | + | switch e := ev.(type) { | |
| 153 | + | case provider.EventStart: | |
| 154 | + | // nothing | |
| 155 | + | case provider.EventTextDelta: | |
| 156 | + | sink(EvTextDelta{Delta: e.Delta}) | |
| 157 | + | case provider.EventToolStart, provider.EventToolArgs, provider.EventToolEnd: | |
| 158 | + | // The provider emits these to support partial-JSON UIs. | |
| 159 | + | // We surface the complete tool call once per block at EvToolCall | |
| 160 | + | // below (after EventDone), so for now we drop them. | |
| 161 | + | case provider.EventUsage: | |
| 162 | + | cum := a.cost.Add(e.Usage) | |
| 163 | + | sink(EvUsage{Usage: e.Usage, Cumulative: cum}) | |
| 164 | + | case provider.EventDone: | |
| 165 | + | stop = e.Stop | |
| 166 | + | finalErr = e.Err | |
| 167 | + | finalMsg = e.Message | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // Append assistant message to transcript. Aborted turns (Esc / Ctrl+C) | |
| 172 | + | // produce partial, mid-sentence content that would confuse subsequent | |
| 173 | + | // turns if it stayed in the transcript — drop it instead. | |
| 174 | + | if len(finalMsg.Content) > 0 && stop != provider.StopAborted { | |
| 175 | + | a.mu.Lock() | |
| 176 | + | a.messages = append(a.messages, finalMsg) | |
| 177 | + | a.mu.Unlock() | |
| 178 | + | sink(EvAssistantMessage{Message: finalMsg}) | |
| 179 | + | // Now surface tool calls as EvToolCall events so UIs can render them | |
| 180 | + | // in order before the tool results arrive. | |
| 181 | + | for _, c := range finalMsg.Content { | |
| 182 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 183 | + | sink(EvToolCall{ID: tc.ID, Name: tc.Name, Args: tc.Arguments}) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | return stop, finalMsg, finalErr | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // executeTools runs every tool call in the assistant message and returns | |
| 192 | + | // a single tool-role message carrying all results. | |
| 193 | + | func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink func(AgentEvent)) (provider.Message, bool) { | |
| 194 | + | var results []provider.Content | |
| 195 | + | hadError := false | |
| 196 | + | ||
| 197 | + | for _, c := range msg.Content { | |
| 198 | + | tc, ok := c.(provider.ToolCallBlock) | |
| 199 | + | if !ok { | |
| 200 | + | continue | |
| 201 | + | } | |
| 202 | + | res := a.runOneTool(ctx, tc, sink) | |
| 203 | + | if res.IsError { | |
| 204 | + | hadError = true | |
| 205 | + | } | |
| 206 | + | results = append(results, provider.ToolResultBlock{ | |
| 207 | + | CallID: tc.ID, | |
| 208 | + | Content: res.Content, | |
| 209 | + | IsError: res.IsError, | |
| 210 | + | }) | |
| 211 | + | sink(EvToolResult{ID: tc.ID, Result: res}) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | return provider.Message{ | |
| 215 | + | Role: provider.RoleTool, | |
| 216 | + | Content: results, | |
| 217 | + | Time: time.Now(), | |
| 218 | + | }, hadError | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult { | |
| 222 | + | tool, err := a.Tools.Get(tc.Name) | |
| 223 | + | if err != nil { | |
| 224 | + | return ToolResult{ | |
| 225 | + | Content: []provider.Content{provider.TextBlock{Text: err.Error()}}, | |
| 226 | + | IsError: true, | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | args := tc.Arguments | |
| 231 | + | if len(args) == 0 { | |
| 232 | + | args = json.RawMessage("{}") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // Recover panics so a buggy tool does not crash the agent. | |
| 236 | + | var res ToolResult | |
| 237 | + | func() { | |
| 238 | + | defer func() { | |
| 239 | + | if r := recover(); r != nil { | |
| 240 | + | res = ToolResult{ | |
| 241 | + | Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}}, | |
| 242 | + | IsError: true, | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | }() | |
| 246 | + | out, err := tool.Execute(ctx, args, func(text string) { | |
| 247 | + | sink(EvToolProgress{ID: tc.ID, Text: text}) | |
| 248 | + | }) | |
| 249 | + | if err != nil { | |
| 250 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 251 | + | res = ToolResult{ | |
| 252 | + | Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}}, | |
| 253 | + | IsError: true, | |
| 254 | + | } | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | res = ToolResult{ | |
| 258 | + | Content: []provider.Content{provider.TextBlock{Text: err.Error()}}, | |
| 259 | + | IsError: true, | |
| 260 | + | } | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | res = out | |
| 264 | + | }() | |
| 265 | + | return res | |
| 266 | + | } | |
A internal/core/compact.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Compact summarizes the agent's transcript via the LLM and replaces | |
| 13 | + | // it with a single synthetic user message carrying the summary. A | |
| 14 | + | // small tail of recent messages is optionally preserved for continuity. | |
| 15 | + | // | |
| 16 | + | // keepTail is the number of most-recent messages to keep verbatim after | |
| 17 | + | // the summary. 0 means summarize everything; a typical useful value is | |
| 18 | + | // 4-8 (last couple of exchanges). | |
| 19 | + | // | |
| 20 | + | // The method blocks until the summary request completes. Emitted | |
| 21 | + | // events via sink are limited to text deltas from the summary call so | |
| 22 | + | // the UI can show progress. | |
| 23 | + | func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta string)) (summary string, err error) { | |
| 24 | + | a.mu.Lock() | |
| 25 | + | msgs := append([]provider.Message(nil), a.messages...) | |
| 26 | + | a.mu.Unlock() | |
| 27 | + | ||
| 28 | + | if len(msgs) == 0 { | |
| 29 | + | return "", fmt.Errorf("nothing to compact") | |
| 30 | + | } | |
| 31 | + | if keepTail < 0 { | |
| 32 | + | keepTail = 0 | |
| 33 | + | } | |
| 34 | + | if keepTail > len(msgs) { | |
| 35 | + | keepTail = len(msgs) | |
| 36 | + | } | |
| 37 | + | summarizable := msgs[:len(msgs)-keepTail] | |
| 38 | + | if len(summarizable) == 0 { | |
| 39 | + | return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Serialize the summarizable transcript to text and wrap it in tags | |
| 43 | + | // so the model treats it as material to summarize, not to continue. | |
| 44 | + | transcript := serializeTranscript(summarizable) | |
| 45 | + | ||
| 46 | + | prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt | |
| 47 | + | ||
| 48 | + | req := provider.Request{ | |
| 49 | + | Model: a.Model, | |
| 50 | + | System: summarizationSystem, | |
| 51 | + | MaxTokens: 4096, | |
| 52 | + | Messages: []provider.Message{ | |
| 53 | + | { | |
| 54 | + | Role: provider.RoleUser, | |
| 55 | + | Content: []provider.Content{provider.TextBlock{Text: prompt}}, | |
| 56 | + | Time: time.Now(), | |
| 57 | + | }, | |
| 58 | + | }, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | stream, err := a.Client.Stream(ctx, req) | |
| 62 | + | if err != nil { | |
| 63 | + | return "", err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | var sb strings.Builder | |
| 67 | + | for ev := range stream { | |
| 68 | + | switch e := ev.(type) { | |
| 69 | + | case provider.EventTextDelta: | |
| 70 | + | sb.WriteString(e.Delta) | |
| 71 | + | if sink != nil { | |
| 72 | + | sink(e.Delta) | |
| 73 | + | } | |
| 74 | + | case provider.EventDone: | |
| 75 | + | if e.Err != nil { | |
| 76 | + | return "", e.Err | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | summary = strings.TrimSpace(sb.String()) | |
| 81 | + | if summary == "" { | |
| 82 | + | return "", fmt.Errorf("empty summary from model") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Replace transcript: one synthetic user message with the summary, | |
| 86 | + | // followed by the preserved tail (if any). | |
| 87 | + | synthetic := provider.Message{ | |
| 88 | + | Role: provider.RoleUser, | |
| 89 | + | Content: []provider.Content{ | |
| 90 | + | provider.TextBlock{Text: "## Context Summary (compacted)\n\n" + summary}, | |
| 91 | + | }, | |
| 92 | + | Time: time.Now(), | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | tail := msgs[len(msgs)-keepTail:] | |
| 96 | + | next := make([]provider.Message, 0, 1+len(tail)) | |
| 97 | + | next = append(next, synthetic) | |
| 98 | + | next = append(next, tail...) | |
| 99 | + | ||
| 100 | + | a.mu.Lock() | |
| 101 | + | a.messages = next | |
| 102 | + | a.mu.Unlock() | |
| 103 | + | ||
| 104 | + | return summary, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // serializeTranscript renders a list of provider.Message into a plain | |
| 108 | + | // text transcript the summarization model can read without trying to | |
| 109 | + | // continue the conversation. | |
| 110 | + | func serializeTranscript(msgs []provider.Message) string { | |
| 111 | + | var sb strings.Builder | |
| 112 | + | for _, m := range msgs { | |
| 113 | + | switch m.Role { | |
| 114 | + | case provider.RoleUser: | |
| 115 | + | sb.WriteString("\n--- user ---\n") | |
| 116 | + | case provider.RoleAssistant: | |
| 117 | + | sb.WriteString("\n--- assistant ---\n") | |
| 118 | + | case provider.RoleTool: | |
| 119 | + | sb.WriteString("\n--- tool ---\n") | |
| 120 | + | } | |
| 121 | + | for _, c := range m.Content { | |
| 122 | + | switch v := c.(type) { | |
| 123 | + | case provider.TextBlock: | |
| 124 | + | sb.WriteString(v.Text) | |
| 125 | + | sb.WriteString("\n") | |
| 126 | + | case provider.ImageBlock: | |
| 127 | + | fmt.Fprintf(&sb, "[image: %s, %d bytes]\n", v.MimeType, len(v.Data)) | |
| 128 | + | case provider.ToolCallBlock: | |
| 129 | + | fmt.Fprintf(&sb, "[tool_call %s %s]\n", v.Name, string(v.Arguments)) | |
| 130 | + | case provider.ToolResultBlock: | |
| 131 | + | for _, inner := range v.Content { | |
| 132 | + | if tb, ok := inner.(provider.TextBlock); ok { | |
| 133 | + | sb.WriteString("[tool_result] ") | |
| 134 | + | sb.WriteString(tb.Text) | |
| 135 | + | sb.WriteString("\n") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return sb.String() | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | const summarizationSystem = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified. | |
| 145 | + | ||
| 146 | + | Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.` | |
| 147 | + | ||
| 148 | + | const compactionPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. | |
| 149 | + | ||
| 150 | + | Use this EXACT format: | |
| 151 | + | ||
| 152 | + | ## Goal | |
| 153 | + | [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] | |
| 154 | + | ||
| 155 | + | ## Constraints & Preferences | |
| 156 | + | - [Any constraints, preferences, or requirements mentioned by user] | |
| 157 | + | - [Or "(none)" if none were mentioned] | |
| 158 | + | ||
| 159 | + | ## Progress | |
| 160 | + | ### Done | |
| 161 | + | - [x] [Completed tasks/changes] | |
| 162 | + | ||
| 163 | + | ### In Progress | |
| 164 | + | - [ ] [Current work] | |
| 165 | + | ||
| 166 | + | ### Blocked | |
| 167 | + | - [Issues preventing progress, if any] | |
| 168 | + | ||
| 169 | + | ## Key Decisions | |
| 170 | + | - **[Decision]**: [Brief rationale] | |
| 171 | + | ||
| 172 | + | ## Next Steps | |
| 173 | + | 1. [Ordered list of what should happen next] | |
| 174 | + | ||
| 175 | + | ## Critical Context | |
| 176 | + | - [Any data, examples, or references needed to continue] | |
| 177 | + | - [Or "(none)" if not applicable] | |
| 178 | + | ||
| 179 | + | Keep each section concise. Preserve exact file paths, function names, and error messages.` | |
A internal/core/core_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSessionRoundTrip(t *testing.T) { | |
| 12 | + | dir := t.TempDir() | |
| 13 | + | os.Setenv("ZOT_HOME", dir) | |
| 14 | + | ||
| 15 | + | sess, err := NewSession(dir, "/tmp/project", "anthropic", "claude-sonnet-4-5", "test") | |
| 16 | + | if err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | msg := provider.Message{ | |
| 20 | + | Role: provider.RoleUser, | |
| 21 | + | Content: []provider.Content{ | |
| 22 | + | provider.TextBlock{Text: "hello"}, | |
| 23 | + | }, | |
| 24 | + | Time: time.Now().UTC(), | |
| 25 | + | } | |
| 26 | + | if err := sess.AppendMessage(msg); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if err := sess.Close(); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | _, msgs, err := OpenSession(sess.Path) | |
| 34 | + | if err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | if len(msgs) != 1 { | |
| 38 | + | t.Fatalf("got %d messages", len(msgs)) | |
| 39 | + | } | |
| 40 | + | tb, ok := msgs[0].Content[0].(provider.TextBlock) | |
| 41 | + | if !ok || tb.Text != "hello" { | |
| 42 | + | t.Fatalf("got %+v", msgs[0]) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestCostAdd(t *testing.T) { | |
| 47 | + | var c CostTracker | |
| 48 | + | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01}) | |
| 49 | + | c.Add(provider.Usage{InputTokens: 200, OutputTokens: 25, CostUSD: 0.02}) | |
| 50 | + | if c.Total.InputTokens != 300 || c.Total.OutputTokens != 75 { | |
| 51 | + | t.Fatalf("got %+v", c.Total) | |
| 52 | + | } | |
| 53 | + | if c.Total.CostUSD < 0.0299 || c.Total.CostUSD > 0.0301 { | |
| 54 | + | t.Fatalf("got cost %v", c.Total.CostUSD) | |
| 55 | + | } | |
| 56 | + | } | |
A internal/core/cost.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "github.com/patriceckhart/zot/internal/provider" | |
| 4 | + | ||
| 5 | + | // CostTracker accumulates usage across turns in a session. | |
| 6 | + | type CostTracker struct { | |
| 7 | + | Total provider.Usage | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // Add folds u into the running total and returns the new cumulative value. | |
| 11 | + | func (c *CostTracker) Add(u provider.Usage) provider.Usage { | |
| 12 | + | c.Total = c.Total.Add(u) | |
| 13 | + | return c.Total | |
| 14 | + | } | |
A internal/core/events.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // AgentEvent is the superset of events emitted by an Agent run. | |
| 10 | + | // Consumers discriminate via a type switch. Each concrete type has a | |
| 11 | + | // Type() method for JSON serialization. | |
| 12 | + | type AgentEvent interface { | |
| 13 | + | Type() string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | type EvTurnStart struct { | |
| 17 | + | Step int | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (EvTurnStart) Type() string { return "turn_start" } | |
| 21 | + | ||
| 22 | + | type EvUserMessage struct { | |
| 23 | + | Message provider.Message | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (EvUserMessage) Type() string { return "user_message" } | |
| 27 | + | ||
| 28 | + | type EvAssistantStart struct{} | |
| 29 | + | ||
| 30 | + | func (EvAssistantStart) Type() string { return "assistant_start" } | |
| 31 | + | ||
| 32 | + | type EvTextDelta struct { | |
| 33 | + | Delta string | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (EvTextDelta) Type() string { return "text_delta" } | |
| 37 | + | ||
| 38 | + | type EvToolCall struct { | |
| 39 | + | ID string | |
| 40 | + | Name string | |
| 41 | + | Args json.RawMessage | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (EvToolCall) Type() string { return "tool_call" } | |
| 45 | + | ||
| 46 | + | type EvToolProgress struct { | |
| 47 | + | ID string | |
| 48 | + | Text string | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (EvToolProgress) Type() string { return "tool_progress" } | |
| 52 | + | ||
| 53 | + | type EvToolResult struct { | |
| 54 | + | ID string | |
| 55 | + | Result ToolResult | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (EvToolResult) Type() string { return "tool_result" } | |
| 59 | + | ||
| 60 | + | type EvUsage struct { | |
| 61 | + | Usage provider.Usage | |
| 62 | + | Cumulative provider.Usage | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (EvUsage) Type() string { return "usage" } | |
| 66 | + | ||
| 67 | + | type EvAssistantMessage struct { | |
| 68 | + | Message provider.Message | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (EvAssistantMessage) Type() string { return "assistant_message" } | |
| 72 | + | ||
| 73 | + | type EvTurnEnd struct { | |
| 74 | + | Stop provider.StopReason | |
| 75 | + | Err error | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (EvTurnEnd) Type() string { return "turn_end" } | |
| 79 | + | ||
| 80 | + | type EvDone struct{} | |
| 81 | + | ||
| 82 | + | func (EvDone) Type() string { return "done" } | |
| 83 | + | ||
| 84 | + | type EvError struct { | |
| 85 | + | Err error | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (EvError) Type() string { return "error" } | |
A internal/core/session.go +367 -0
| @@ -0,0 +1,367 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sort" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/google/uuid" | |
| 16 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Session is a JSONL-backed conversation transcript tied to a cwd. | |
| 20 | + | type Session struct { | |
| 21 | + | ID string | |
| 22 | + | Path string | |
| 23 | + | Meta SessionMeta | |
| 24 | + | writer *os.File | |
| 25 | + | buf *bufio.Writer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // SessionMeta is written as the first line of every session file. | |
| 29 | + | type SessionMeta struct { | |
| 30 | + | ID string `json:"id"` | |
| 31 | + | CWD string `json:"cwd"` | |
| 32 | + | Model string `json:"model"` | |
| 33 | + | Provider string `json:"provider"` | |
| 34 | + | Started time.Time `json:"started"` | |
| 35 | + | Version string `json:"version"` | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // sessionLine is the on-disk row type. Message is kept as a raw | |
| 39 | + | // JSON message on reads (because Content is an interface slice that | |
| 40 | + | // the default unmarshaler cannot reconstruct); it is written with a | |
| 41 | + | // regular provider.Message value. | |
| 42 | + | type sessionLine struct { | |
| 43 | + | Type string `json:"type"` | |
| 44 | + | Meta *SessionMeta `json:"meta,omitempty"` | |
| 45 | + | Message *provider.Message `json:"message,omitempty"` | |
| 46 | + | Usage *provider.Usage `json:"usage,omitempty"` | |
| 47 | + | Cumulative *provider.Usage `json:"cumulative,omitempty"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type sessionLineHead struct { | |
| 51 | + | Type string `json:"type"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // SessionsDir returns the per-cwd sessions directory under root. | |
| 55 | + | func SessionsDir(root, cwd string) string { | |
| 56 | + | sum := sha256.Sum256([]byte(cwd)) | |
| 57 | + | short := hex.EncodeToString(sum[:8]) | |
| 58 | + | return filepath.Join(root, "sessions", short) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // NewSession creates and opens a new session file. | |
| 62 | + | func NewSession(root, cwd, providerName, model, version string) (*Session, error) { | |
| 63 | + | dir := SessionsDir(root, cwd) | |
| 64 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 65 | + | return nil, err | |
| 66 | + | } | |
| 67 | + | id := uuid.NewString() | |
| 68 | + | name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8]) | |
| 69 | + | p := filepath.Join(dir, name) | |
| 70 | + | f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) | |
| 71 | + | if err != nil { | |
| 72 | + | return nil, err | |
| 73 | + | } | |
| 74 | + | s := &Session{ | |
| 75 | + | ID: id, | |
| 76 | + | Path: p, | |
| 77 | + | Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version}, | |
| 78 | + | writer: f, | |
| 79 | + | buf: bufio.NewWriter(f), | |
| 80 | + | } | |
| 81 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 82 | + | f.Close() | |
| 83 | + | return nil, err | |
| 84 | + | } | |
| 85 | + | return s, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // OpenSession opens an existing session for appending. | |
| 89 | + | func OpenSession(path string) (*Session, []provider.Message, error) { | |
| 90 | + | f, err := os.Open(path) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, nil, err | |
| 93 | + | } | |
| 94 | + | defer f.Close() | |
| 95 | + | ||
| 96 | + | var meta SessionMeta | |
| 97 | + | var messages []provider.Message | |
| 98 | + | sc := bufio.NewScanner(f) | |
| 99 | + | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| 100 | + | for sc.Scan() { | |
| 101 | + | var head sessionLineHead | |
| 102 | + | if err := json.Unmarshal(sc.Bytes(), &head); err != nil { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | switch head.Type { | |
| 106 | + | case "meta": | |
| 107 | + | var row struct { | |
| 108 | + | Meta SessionMeta `json:"meta"` | |
| 109 | + | } | |
| 110 | + | if err := json.Unmarshal(sc.Bytes(), &row); err == nil { | |
| 111 | + | meta = row.Meta | |
| 112 | + | } | |
| 113 | + | case "message": | |
| 114 | + | if msg, err := hydrateMessage(sc.Bytes()); err == nil && len(msg.Content) > 0 { | |
| 115 | + | messages = append(messages, msg) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | if err := sc.Err(); err != nil { | |
| 120 | + | return nil, nil, err | |
| 121 | + | } | |
| 122 | + | out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) | |
| 123 | + | if err != nil { | |
| 124 | + | return nil, nil, err | |
| 125 | + | } | |
| 126 | + | s := &Session{ID: meta.ID, Path: path, Meta: meta, writer: out, buf: bufio.NewWriter(out)} | |
| 127 | + | return s, messages, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // LatestSession returns the most recent session file for cwd, or "". | |
| 131 | + | func LatestSession(root, cwd string) string { | |
| 132 | + | paths := ListSessions(root, cwd) | |
| 133 | + | if len(paths) == 0 { | |
| 134 | + | return "" | |
| 135 | + | } | |
| 136 | + | return paths[0] | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SessionSummary describes one on-disk session at a glance for UI pickers. | |
| 140 | + | type SessionSummary struct { | |
| 141 | + | Path string | |
| 142 | + | Started time.Time | |
| 143 | + | Model string | |
| 144 | + | Provider string | |
| 145 | + | MessageCount int | |
| 146 | + | FirstUserText string | |
| 147 | + | TotalCost float64 | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // DescribeSessions returns lightweight summaries for every session in | |
| 151 | + | // cwd, newest first. Parses only the first few lines and the last usage | |
| 152 | + | // line so it's cheap to run on every dialog open. | |
| 153 | + | func DescribeSessions(root, cwd string) []SessionSummary { | |
| 154 | + | paths := ListSessions(root, cwd) | |
| 155 | + | summaries := make([]SessionSummary, 0, len(paths)) | |
| 156 | + | for _, p := range paths { | |
| 157 | + | summaries = append(summaries, describeSession(p)) | |
| 158 | + | } | |
| 159 | + | return summaries | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func describeSession(path string) SessionSummary { | |
| 163 | + | s := SessionSummary{Path: path} | |
| 164 | + | f, err := os.Open(path) | |
| 165 | + | if err != nil { | |
| 166 | + | return s | |
| 167 | + | } | |
| 168 | + | defer f.Close() | |
| 169 | + | sc := bufio.NewScanner(f) | |
| 170 | + | sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) | |
| 171 | + | for sc.Scan() { | |
| 172 | + | var head sessionLineHead | |
| 173 | + | if err := json.Unmarshal(sc.Bytes(), &head); err != nil { | |
| 174 | + | continue | |
| 175 | + | } | |
| 176 | + | switch head.Type { | |
| 177 | + | case "meta": | |
| 178 | + | var row struct { | |
| 179 | + | Meta SessionMeta `json:"meta"` | |
| 180 | + | } | |
| 181 | + | if err := json.Unmarshal(sc.Bytes(), &row); err == nil { | |
| 182 | + | s.Started = row.Meta.Started | |
| 183 | + | s.Model = row.Meta.Model | |
| 184 | + | s.Provider = row.Meta.Provider | |
| 185 | + | } | |
| 186 | + | case "message": | |
| 187 | + | s.MessageCount++ | |
| 188 | + | if s.FirstUserText == "" { | |
| 189 | + | s.FirstUserText = firstUserText(sc.Bytes()) | |
| 190 | + | } | |
| 191 | + | case "usage": | |
| 192 | + | var row struct { | |
| 193 | + | Cumulative provider.Usage `json:"cumulative"` | |
| 194 | + | } | |
| 195 | + | if err := json.Unmarshal(sc.Bytes(), &row); err == nil { | |
| 196 | + | s.TotalCost = row.Cumulative.CostUSD | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | return s | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func firstUserText(line []byte) string { | |
| 204 | + | var row struct { | |
| 205 | + | Message struct { | |
| 206 | + | Role string `json:"role"` | |
| 207 | + | Content []struct { | |
| 208 | + | Text string `json:"text"` | |
| 209 | + | } `json:"content"` | |
| 210 | + | } `json:"message"` | |
| 211 | + | } | |
| 212 | + | if err := json.Unmarshal(line, &row); err != nil { | |
| 213 | + | return "" | |
| 214 | + | } | |
| 215 | + | if row.Message.Role != "user" { | |
| 216 | + | return "" | |
| 217 | + | } | |
| 218 | + | for _, c := range row.Message.Content { | |
| 219 | + | if c.Text != "" { | |
| 220 | + | return c.Text | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | return "" | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // ListSessions returns session file paths for cwd, newest first. | |
| 227 | + | func ListSessions(root, cwd string) []string { | |
| 228 | + | dir := SessionsDir(root, cwd) | |
| 229 | + | entries, err := os.ReadDir(dir) | |
| 230 | + | if err != nil { | |
| 231 | + | return nil | |
| 232 | + | } | |
| 233 | + | var files []string | |
| 234 | + | for _, e := range entries { | |
| 235 | + | if !e.IsDir() && strings.HasSuffix(e.Name(), ".jsonl") { | |
| 236 | + | files = append(files, filepath.Join(dir, e.Name())) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | sort.Sort(sort.Reverse(sort.StringSlice(files))) | |
| 240 | + | return files | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // AppendMessage writes a message to the session. | |
| 244 | + | func (s *Session) AppendMessage(m provider.Message) error { | |
| 245 | + | if s == nil { | |
| 246 | + | return nil | |
| 247 | + | } | |
| 248 | + | return s.writeLine(sessionLine{Type: "message", Message: &m}) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // UpdateModel records a provider/model switch in the session file. | |
| 252 | + | // The reader keeps the most recent meta entry, so the session resumes | |
| 253 | + | // with the updated model. | |
| 254 | + | func (s *Session) UpdateModel(providerName, model string) error { | |
| 255 | + | if s == nil { | |
| 256 | + | return nil | |
| 257 | + | } | |
| 258 | + | s.Meta.Provider = providerName | |
| 259 | + | s.Meta.Model = model | |
| 260 | + | return s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // AppendUsage writes a usage row to the session. | |
| 264 | + | func (s *Session) AppendUsage(u, cum provider.Usage) error { | |
| 265 | + | if s == nil { | |
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum}) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Close flushes and closes the session file. | |
| 272 | + | func (s *Session) Close() error { | |
| 273 | + | if s == nil { | |
| 274 | + | return nil | |
| 275 | + | } | |
| 276 | + | if err := s.buf.Flush(); err != nil { | |
| 277 | + | s.writer.Close() | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | return s.writer.Close() | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func (s *Session) writeLine(row sessionLine) error { | |
| 284 | + | b, err := json.Marshal(row) | |
| 285 | + | if err != nil { | |
| 286 | + | return err | |
| 287 | + | } | |
| 288 | + | if _, err := s.buf.Write(b); err != nil { | |
| 289 | + | return err | |
| 290 | + | } | |
| 291 | + | if err := s.buf.WriteByte('\n'); err != nil { | |
| 292 | + | return err | |
| 293 | + | } | |
| 294 | + | return s.buf.Flush() | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // ---- content (de)serialization ---- | |
| 298 | + | // | |
| 299 | + | // provider.Content is an interface; encoding/json drops type information. | |
| 300 | + | // We persist messages by reading the raw "message" object back and | |
| 301 | + | // rebuilding Content from discriminated fields. | |
| 302 | + | ||
| 303 | + | func hydrateMessage(lineBytes []byte) (provider.Message, error) { | |
| 304 | + | var row struct { | |
| 305 | + | Message struct { | |
| 306 | + | Role provider.Role `json:"role"` | |
| 307 | + | Content []json.RawMessage `json:"content"` | |
| 308 | + | Time time.Time `json:"time"` | |
| 309 | + | } `json:"message"` | |
| 310 | + | } | |
| 311 | + | if err := json.Unmarshal(lineBytes, &row); err != nil { | |
| 312 | + | return provider.Message{}, err | |
| 313 | + | } | |
| 314 | + | msg := provider.Message{Role: row.Message.Role, Time: row.Message.Time} | |
| 315 | + | for _, raw := range row.Message.Content { | |
| 316 | + | var head struct { | |
| 317 | + | Text string `json:"text"` | |
| 318 | + | MimeType string `json:"mime_type"` | |
| 319 | + | Data []byte `json:"data"` | |
| 320 | + | ID string `json:"id"` | |
| 321 | + | Name string `json:"name"` | |
| 322 | + | CallID string `json:"call_id"` | |
| 323 | + | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | |
| 324 | + | } | |
| 325 | + | if err := json.Unmarshal(raw, &head); err != nil { | |
| 326 | + | continue | |
| 327 | + | } | |
| 328 | + | // Discriminate by presence of fields. | |
| 329 | + | switch { | |
| 330 | + | case head.Name != "" && head.ID != "": | |
| 331 | + | var tc struct { | |
| 332 | + | ID string `json:"id"` | |
| 333 | + | Name string `json:"name"` | |
| 334 | + | Arguments json.RawMessage `json:"arguments"` | |
| 335 | + | } | |
| 336 | + | _ = json.Unmarshal(raw, &tc) | |
| 337 | + | msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}) | |
| 338 | + | case head.CallID != "": | |
| 339 | + | var tr struct { | |
| 340 | + | CallID string `json:"call_id"` | |
| 341 | + | Content []json.RawMessage `json:"content"` | |
| 342 | + | IsError bool `json:"is_error"` | |
| 343 | + | } | |
| 344 | + | _ = json.Unmarshal(raw, &tr) | |
| 345 | + | block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError} | |
| 346 | + | for _, c := range tr.Content { | |
| 347 | + | var inner struct { | |
| 348 | + | Text string `json:"text"` | |
| 349 | + | MimeType string `json:"mime_type"` | |
| 350 | + | Data []byte `json:"data"` | |
| 351 | + | } | |
| 352 | + | _ = json.Unmarshal(c, &inner) | |
| 353 | + | if inner.MimeType != "" { | |
| 354 | + | block.Content = append(block.Content, provider.ImageBlock{MimeType: inner.MimeType, Data: inner.Data}) | |
| 355 | + | } else { | |
| 356 | + | block.Content = append(block.Content, provider.TextBlock{Text: inner.Text}) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | msg.Content = append(msg.Content, block) | |
| 360 | + | case head.MimeType != "": | |
| 361 | + | msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data}) | |
| 362 | + | default: | |
| 363 | + | msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text}) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | return msg, nil | |
| 367 | + | } | |
A internal/core/tool.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // Package core implements the agent loop, tool runtime, and session | |
| 2 | + | // persistence. It is provider-agnostic: it talks to an LLM only through | |
| 3 | + | // the provider.Client interface. | |
| 4 | + | package core | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "fmt" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Tool is a capability the agent can invoke. | |
| 15 | + | type Tool interface { | |
| 16 | + | // Name is the unique tool id shown to the LLM. | |
| 17 | + | Name() string | |
| 18 | + | // Description is a one-line summary shown to the LLM. | |
| 19 | + | Description() string | |
| 20 | + | // Schema is a JSON Schema object for Execute's args. | |
| 21 | + | Schema() json.RawMessage | |
| 22 | + | // Execute runs the tool. progress may be called any number of times | |
| 23 | + | // with partial textual output (for UIs); it is not sent to the LLM. | |
| 24 | + | Execute(ctx context.Context, args json.RawMessage, progress func(string)) (ToolResult, error) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // ToolResult is the outcome of Tool.Execute. | |
| 28 | + | type ToolResult struct { | |
| 29 | + | // Content is sent back to the LLM (text and/or images). | |
| 30 | + | Content []provider.Content | |
| 31 | + | // IsError marks this result as an error to the LLM. | |
| 32 | + | IsError bool | |
| 33 | + | // Details is arbitrary data for UIs and logs; not sent to the LLM. | |
| 34 | + | Details any | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Registry is a name->Tool map. | |
| 38 | + | type Registry map[string]Tool | |
| 39 | + | ||
| 40 | + | // NewRegistry builds a Registry from a list of tools. | |
| 41 | + | func NewRegistry(tools ...Tool) Registry { | |
| 42 | + | r := Registry{} | |
| 43 | + | for _, t := range tools { | |
| 44 | + | r[t.Name()] = t | |
| 45 | + | } | |
| 46 | + | return r | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Specs returns the tool definitions to advertise to the LLM. | |
| 50 | + | func (r Registry) Specs() []provider.Tool { | |
| 51 | + | out := make([]provider.Tool, 0, len(r)) | |
| 52 | + | for _, t := range r { | |
| 53 | + | out = append(out, provider.Tool{ | |
| 54 | + | Name: t.Name(), | |
| 55 | + | Description: t.Description(), | |
| 56 | + | Schema: t.Schema(), | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | return out | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Get looks up a tool by name. | |
| 63 | + | func (r Registry) Get(name string) (Tool, error) { | |
| 64 | + | t, ok := r[name] | |
| 65 | + | if !ok { | |
| 66 | + | return nil, fmt.Errorf("unknown tool %q", name) | |
| 67 | + | } | |
| 68 | + | return t, nil | |
| 69 | + | } | |
A internal/provider/anthropic.go +620 -0
| @@ -0,0 +1,620 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const anthropicDefaultBaseURL = "https://api.anthropic.com" | |
| 16 | + | const anthropicAPIVersion = "2023-06-01" | |
| 17 | + | ||
| 18 | + | // Stealth identity used when talking to Anthropic via subscription OAuth. | |
| 19 | + | // These values mimic the official Claude Code CLI so Anthropic's edge | |
| 20 | + | // accepts the request; diverging from them causes 429 rate_limit_error | |
| 21 | + | // or 403 on the very first request. | |
| 22 | + | const ( | |
| 23 | + | claudeCodeVersion = "2.1.75" | |
| 24 | + | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Claude Code's canonical tool casing. When running under OAuth we must | |
| 28 | + | // advertise tool names that match this list (case-insensitive lookup), | |
| 29 | + | // because Anthropic's backend cross-checks them. | |
| 30 | + | var claudeCodeToolNames = map[string]string{ | |
| 31 | + | "read": "Read", | |
| 32 | + | "write": "Write", | |
| 33 | + | "edit": "Edit", | |
| 34 | + | "bash": "Bash", | |
| 35 | + | "grep": "Grep", | |
| 36 | + | "glob": "Glob", | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func toClaudeCodeToolName(name string) string { | |
| 40 | + | if cc, ok := claudeCodeToolNames[strings.ToLower(name)]; ok { | |
| 41 | + | return cc | |
| 42 | + | } | |
| 43 | + | return name | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func fromClaudeCodeToolName(name string, tools []Tool) string { | |
| 47 | + | lower := strings.ToLower(name) | |
| 48 | + | for _, t := range tools { | |
| 49 | + | if strings.ToLower(t.Name) == lower { | |
| 50 | + | return t.Name | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return name | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // anthropicClient implements Client against the Anthropic Messages API. | |
| 57 | + | type anthropicClient struct { | |
| 58 | + | apiKey string | |
| 59 | + | baseURL string | |
| 60 | + | oauthTok string // when non-empty, send Bearer auth instead of x-api-key | |
| 61 | + | http *http.Client | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // NewAnthropic creates an Anthropic client using an API key. baseURL may be empty. | |
| 65 | + | func NewAnthropic(apiKey, baseURL string) Client { | |
| 66 | + | if baseURL == "" { | |
| 67 | + | baseURL = anthropicDefaultBaseURL | |
| 68 | + | } | |
| 69 | + | return &anthropicClient{ | |
| 70 | + | apiKey: apiKey, | |
| 71 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 72 | + | http: &http.Client{Timeout: 0}, | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token. | |
| 77 | + | func NewAnthropicOAuth(accessToken, baseURL string) Client { | |
| 78 | + | if baseURL == "" { | |
| 79 | + | baseURL = anthropicDefaultBaseURL | |
| 80 | + | } | |
| 81 | + | return &anthropicClient{ | |
| 82 | + | oauthTok: accessToken, | |
| 83 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 84 | + | http: &http.Client{Timeout: 0}, | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (c *anthropicClient) Name() string { return "anthropic" } | |
| 89 | + | ||
| 90 | + | // ---- wire types ---- | |
| 91 | + | ||
| 92 | + | type anthTextBlock struct { | |
| 93 | + | Type string `json:"type"` // "text" | |
| 94 | + | Text string `json:"text"` | |
| 95 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | type anthImageSource struct { | |
| 99 | + | Type string `json:"type"` // "base64" | |
| 100 | + | MediaType string `json:"media_type"` | |
| 101 | + | Data string `json:"data"` | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | type anthImageBlock struct { | |
| 105 | + | Type string `json:"type"` // "image" | |
| 106 | + | Source anthImageSource `json:"source"` | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | type anthToolUseBlock struct { | |
| 110 | + | Type string `json:"type"` // "tool_use" | |
| 111 | + | ID string `json:"id"` | |
| 112 | + | Name string `json:"name"` | |
| 113 | + | Input json.RawMessage `json:"input"` | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | type anthToolResultBlock struct { | |
| 117 | + | Type string `json:"type"` // "tool_result" | |
| 118 | + | ToolUseID string `json:"tool_use_id"` | |
| 119 | + | Content json.RawMessage `json:"content"` // string or array of blocks | |
| 120 | + | IsError bool `json:"is_error,omitempty"` | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | type anthCacheCtrl struct { | |
| 124 | + | Type string `json:"type"` // "ephemeral" | |
| 125 | + | TTL string `json:"ttl,omitempty"` | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | type anthMessage struct { | |
| 129 | + | Role string `json:"role"` | |
| 130 | + | Content []interface{} `json:"content"` | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | type anthSystemBlock struct { | |
| 134 | + | Type string `json:"type"` // "text" | |
| 135 | + | Text string `json:"text"` | |
| 136 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | type anthTool struct { | |
| 140 | + | Name string `json:"name"` | |
| 141 | + | Description string `json:"description,omitempty"` | |
| 142 | + | InputSchema json.RawMessage `json:"input_schema"` | |
| 143 | + | CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | type anthThinking struct { | |
| 147 | + | Type string `json:"type"` // "enabled" | |
| 148 | + | BudgetTokens int `json:"budget_tokens"` | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | type anthRequest struct { | |
| 152 | + | Model string `json:"model"` | |
| 153 | + | MaxTokens int `json:"max_tokens"` | |
| 154 | + | System []anthSystemBlock `json:"system,omitempty"` | |
| 155 | + | Messages []anthMessage `json:"messages"` | |
| 156 | + | Tools []anthTool `json:"tools,omitempty"` | |
| 157 | + | Temperature *float32 `json:"temperature,omitempty"` | |
| 158 | + | Thinking *anthThinking `json:"thinking,omitempty"` | |
| 159 | + | Stream bool `json:"stream"` | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // ---- request building ---- | |
| 163 | + | ||
| 164 | + | func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |
| 165 | + | m, err := FindModel("anthropic", req.Model) | |
| 166 | + | if err != nil { | |
| 167 | + | return nil, err | |
| 168 | + | } | |
| 169 | + | maxTok := req.MaxTokens | |
| 170 | + | if maxTok <= 0 { | |
| 171 | + | maxTok = m.MaxOutput | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | out := &anthRequest{ | |
| 175 | + | Model: req.Model, | |
| 176 | + | MaxTokens: maxTok, | |
| 177 | + | Temperature: req.Temperature, | |
| 178 | + | Stream: true, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // System prompt assembly differs between api-key and OAuth modes. | |
| 182 | + | // OAuth requests MUST begin with the Claude Code identity line or | |
| 183 | + | // Anthropic rejects them (429 rate_limit_error with zero tokens used). | |
| 184 | + | if c.oauthTok != "" { | |
| 185 | + | out.System = []anthSystemBlock{{ | |
| 186 | + | Type: "text", | |
| 187 | + | Text: claudeCodeIdentity, | |
| 188 | + | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 189 | + | }} | |
| 190 | + | if req.System != "" { | |
| 191 | + | out.System = append(out.System, anthSystemBlock{ | |
| 192 | + | Type: "text", | |
| 193 | + | Text: req.System, | |
| 194 | + | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | } else if req.System != "" { | |
| 198 | + | out.System = []anthSystemBlock{{ | |
| 199 | + | Type: "text", | |
| 200 | + | Text: req.System, | |
| 201 | + | CacheControl: &anthCacheCtrl{Type: "ephemeral"}, | |
| 202 | + | }} | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | if req.Reasoning != "" && m.Reasoning { | |
| 206 | + | budget := anthropicReasoningBudget(req.Reasoning) | |
| 207 | + | if budget > 0 { | |
| 208 | + | out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget} | |
| 209 | + | // Reasoning requires max_tokens > budget. | |
| 210 | + | if out.MaxTokens <= budget { | |
| 211 | + | out.MaxTokens = budget + 4096 | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | for _, t := range req.Tools { | |
| 217 | + | name := t.Name | |
| 218 | + | if c.oauthTok != "" { | |
| 219 | + | name = toClaudeCodeToolName(name) | |
| 220 | + | } | |
| 221 | + | out.Tools = append(out.Tools, anthTool{ | |
| 222 | + | Name: name, | |
| 223 | + | Description: t.Description, | |
| 224 | + | InputSchema: t.Schema, | |
| 225 | + | }) | |
| 226 | + | } | |
| 227 | + | // Cache the last tool definition (applies cache breakpoint to the whole tools array). | |
| 228 | + | if n := len(out.Tools); n > 0 { | |
| 229 | + | out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"} | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Group messages: consecutive user/tool roles into one "user" message. | |
| 233 | + | // Anthropic only has roles "user" and "assistant"; tool_result blocks live in user messages. | |
| 234 | + | for _, msg := range req.Messages { | |
| 235 | + | renameTools := c.oauthTok != "" | |
| 236 | + | switch msg.Role { | |
| 237 | + | case RoleUser: | |
| 238 | + | out.Messages = append(out.Messages, anthMessage{ | |
| 239 | + | Role: "user", | |
| 240 | + | Content: convertAnthContent(msg.Content, renameTools), | |
| 241 | + | }) | |
| 242 | + | case RoleTool: | |
| 243 | + | // Attach tool_result blocks to a user message; merge with prior user msg if last. | |
| 244 | + | blocks := convertAnthContent(msg.Content, renameTools) | |
| 245 | + | if n := len(out.Messages); n > 0 && out.Messages[n-1].Role == "user" { | |
| 246 | + | out.Messages[n-1].Content = append(out.Messages[n-1].Content, blocks...) | |
| 247 | + | } else { | |
| 248 | + | out.Messages = append(out.Messages, anthMessage{Role: "user", Content: blocks}) | |
| 249 | + | } | |
| 250 | + | case RoleAssistant: | |
| 251 | + | out.Messages = append(out.Messages, anthMessage{ | |
| 252 | + | Role: "assistant", | |
| 253 | + | Content: convertAnthContent(msg.Content, renameTools), | |
| 254 | + | }) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | return out, nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func anthropicReasoningBudget(level string) int { | |
| 262 | + | switch strings.ToLower(level) { | |
| 263 | + | case "low": | |
| 264 | + | return 2048 | |
| 265 | + | case "medium": | |
| 266 | + | return 8192 | |
| 267 | + | case "high": | |
| 268 | + | return 16384 | |
| 269 | + | default: | |
| 270 | + | return 0 | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func convertAnthContent(blocks []Content, renameTools bool) []interface{} { | |
| 275 | + | out := make([]interface{}, 0, len(blocks)) | |
| 276 | + | for _, b := range blocks { | |
| 277 | + | switch v := b.(type) { | |
| 278 | + | case TextBlock: | |
| 279 | + | if v.Text == "" { | |
| 280 | + | continue | |
| 281 | + | } | |
| 282 | + | out = append(out, anthTextBlock{Type: "text", Text: v.Text}) | |
| 283 | + | case ImageBlock: | |
| 284 | + | out = append(out, anthImageBlock{ | |
| 285 | + | Type: "image", | |
| 286 | + | Source: anthImageSource{ | |
| 287 | + | Type: "base64", | |
| 288 | + | MediaType: v.MimeType, | |
| 289 | + | Data: base64.StdEncoding.EncodeToString(v.Data), | |
| 290 | + | }, | |
| 291 | + | }) | |
| 292 | + | case ToolCallBlock: | |
| 293 | + | args := v.Arguments | |
| 294 | + | if len(args) == 0 { | |
| 295 | + | args = json.RawMessage("{}") | |
| 296 | + | } | |
| 297 | + | name := v.Name | |
| 298 | + | if renameTools { | |
| 299 | + | name = toClaudeCodeToolName(name) | |
| 300 | + | } | |
| 301 | + | out = append(out, anthToolUseBlock{ | |
| 302 | + | Type: "tool_use", ID: v.ID, Name: name, Input: args, | |
| 303 | + | }) | |
| 304 | + | case ToolResultBlock: | |
| 305 | + | // Flatten content to a string if all text; else array of blocks. | |
| 306 | + | content, _ := anthBuildToolResultContent(v.Content) | |
| 307 | + | out = append(out, anthToolResultBlock{ | |
| 308 | + | Type: "tool_result", | |
| 309 | + | ToolUseID: v.CallID, | |
| 310 | + | Content: content, | |
| 311 | + | IsError: v.IsError, | |
| 312 | + | }) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | return out | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) { | |
| 319 | + | onlyText := true | |
| 320 | + | var sb strings.Builder | |
| 321 | + | for _, b := range blocks { | |
| 322 | + | if tb, ok := b.(TextBlock); ok { | |
| 323 | + | if sb.Len() > 0 { | |
| 324 | + | sb.WriteString("\n") | |
| 325 | + | } | |
| 326 | + | sb.WriteString(tb.Text) | |
| 327 | + | } else { | |
| 328 | + | onlyText = false | |
| 329 | + | break | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | if onlyText { | |
| 333 | + | if sb.Len() == 0 { | |
| 334 | + | return json.Marshal("") | |
| 335 | + | } | |
| 336 | + | return json.Marshal(sb.String()) | |
| 337 | + | } | |
| 338 | + | // Array form: text + image blocks. | |
| 339 | + | arr := make([]interface{}, 0, len(blocks)) | |
| 340 | + | for _, b := range blocks { | |
| 341 | + | switch v := b.(type) { | |
| 342 | + | case TextBlock: | |
| 343 | + | arr = append(arr, anthTextBlock{Type: "text", Text: v.Text}) | |
| 344 | + | case ImageBlock: | |
| 345 | + | arr = append(arr, anthImageBlock{ | |
| 346 | + | Type: "image", | |
| 347 | + | Source: anthImageSource{ | |
| 348 | + | Type: "base64", | |
| 349 | + | MediaType: v.MimeType, | |
| 350 | + | Data: base64.StdEncoding.EncodeToString(v.Data), | |
| 351 | + | }, | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | return json.Marshal(arr) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // ---- streaming ---- | |
| 359 | + | ||
| 360 | + | func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 361 | + | wire, err := c.buildRequest(req) | |
| 362 | + | if err != nil { | |
| 363 | + | return nil, err | |
| 364 | + | } | |
| 365 | + | body, err := json.Marshal(wire) | |
| 366 | + | if err != nil { | |
| 367 | + | return nil, err | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body)) | |
| 371 | + | if err != nil { | |
| 372 | + | return nil, err | |
| 373 | + | } | |
| 374 | + | httpReq.Header.Set("content-type", "application/json") | |
| 375 | + | httpReq.Header.Set("anthropic-version", anthropicAPIVersion) | |
| 376 | + | if c.oauthTok != "" { | |
| 377 | + | // Claude-Code-shaped request: identical headers and values as the | |
| 378 | + | // official CLI. Any drift triggers Anthropic's anti-abuse check and | |
| 379 | + | // rate-limits (or outright blocks) the request. | |
| 380 | + | httpReq.Header.Set("accept", "application/json") | |
| 381 | + | httpReq.Header.Set("authorization", "Bearer "+c.oauthTok) | |
| 382 | + | httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14") | |
| 383 | + | httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true") | |
| 384 | + | httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion+" (external, cli)") | |
| 385 | + | httpReq.Header.Set("x-app", "cli") | |
| 386 | + | // Remove x-api-key entirely by NOT setting it. | |
| 387 | + | } else { | |
| 388 | + | httpReq.Header.Set("accept", "text/event-stream") | |
| 389 | + | httpReq.Header.Set("x-api-key", c.apiKey) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | resp, err := c.http.Do(httpReq) | |
| 393 | + | if err != nil { | |
| 394 | + | return nil, fmt.Errorf("anthropic: %w", err) | |
| 395 | + | } | |
| 396 | + | if resp.StatusCode != http.StatusOK { | |
| 397 | + | b, _ := io.ReadAll(resp.Body) | |
| 398 | + | resp.Body.Close() | |
| 399 | + | return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | out := make(chan Event, 16) | |
| 403 | + | go c.runStream(ctx, resp, req, out) | |
| 404 | + | return out, nil | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { | |
| 408 | + | defer close(out) | |
| 409 | + | defer resp.Body.Close() | |
| 410 | + | ||
| 411 | + | model, _ := FindModel("anthropic", req.Model) | |
| 412 | + | out <- EventStart{Model: req.Model, Provider: "anthropic"} | |
| 413 | + | ||
| 414 | + | raw := make(chan sseEvent, 16) | |
| 415 | + | go readSSE(resp.Body, raw) | |
| 416 | + | ||
| 417 | + | // State for assembling the assistant message. Blocks are indexed | |
| 418 | + | // by their `index` field from Anthropic so we can preserve the | |
| 419 | + | // interleaved order the model emitted them in (text may come | |
| 420 | + | // before OR after tool_use; mixing both happens frequently). | |
| 421 | + | type blockEntry struct { | |
| 422 | + | kind string // "text" | "tool_use" | |
| 423 | + | textBuf strings.Builder | |
| 424 | + | toolCall ToolCallBlock | |
| 425 | + | toolArgs strings.Builder | |
| 426 | + | } | |
| 427 | + | var ( | |
| 428 | + | blocks = map[int]*blockEntry{} | |
| 429 | + | blockOrder []int // insertion order of indexes | |
| 430 | + | activeIdx = -1 | |
| 431 | + | usage Usage | |
| 432 | + | stop StopReason = StopEnd | |
| 433 | + | finalErr error | |
| 434 | + | ) | |
| 435 | + | _ = activeIdx // read-only indicator used for legacy parity | |
| 436 | + | ||
| 437 | + | registerBlock := func(idx int, kind string) *blockEntry { | |
| 438 | + | if be, ok := blocks[idx]; ok { | |
| 439 | + | return be | |
| 440 | + | } | |
| 441 | + | be := &blockEntry{kind: kind} | |
| 442 | + | blocks[idx] = be | |
| 443 | + | blockOrder = append(blockOrder, idx) | |
| 444 | + | return be | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | assembleMsg := func() Message { | |
| 448 | + | content := []Content{} | |
| 449 | + | for _, idx := range blockOrder { | |
| 450 | + | be := blocks[idx] | |
| 451 | + | switch be.kind { | |
| 452 | + | case "text": | |
| 453 | + | if be.textBuf.Len() > 0 { | |
| 454 | + | content = append(content, TextBlock{Text: be.textBuf.String()}) | |
| 455 | + | } | |
| 456 | + | case "tool_use": | |
| 457 | + | tc := be.toolCall | |
| 458 | + | args := be.toolArgs.String() | |
| 459 | + | if args == "" { | |
| 460 | + | args = "{}" | |
| 461 | + | } | |
| 462 | + | tc.Arguments = json.RawMessage(args) | |
| 463 | + | content = append(content, tc) | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | return Message{Role: RoleAssistant, Content: content, Time: time.Now()} | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | sendDone := func() { | |
| 470 | + | usage.CostUSD = ComputeCost(model, usage) | |
| 471 | + | out <- EventUsage{Usage: usage} | |
| 472 | + | out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()} | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | for { | |
| 476 | + | select { | |
| 477 | + | case <-ctx.Done(): | |
| 478 | + | stop = StopAborted | |
| 479 | + | finalErr = ctx.Err() | |
| 480 | + | sendDone() | |
| 481 | + | return | |
| 482 | + | case ev, ok := <-raw: | |
| 483 | + | if !ok { | |
| 484 | + | sendDone() | |
| 485 | + | return | |
| 486 | + | } | |
| 487 | + | // Parse event payload based on event: type. | |
| 488 | + | var payload map[string]json.RawMessage | |
| 489 | + | if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil { | |
| 490 | + | continue | |
| 491 | + | } | |
| 492 | + | switch ev.Event { | |
| 493 | + | case "content_block_start": | |
| 494 | + | var idx int | |
| 495 | + | if b, ok := payload["index"]; ok { | |
| 496 | + | _ = json.Unmarshal(b, &idx) | |
| 497 | + | } | |
| 498 | + | var block struct { | |
| 499 | + | Type string `json:"type"` | |
| 500 | + | ID string `json:"id"` | |
| 501 | + | Name string `json:"name"` | |
| 502 | + | Text string `json:"text"` | |
| 503 | + | Input json.RawMessage `json:"input"` | |
| 504 | + | } | |
| 505 | + | if b, ok := payload["content_block"]; ok { | |
| 506 | + | _ = json.Unmarshal(b, &block) | |
| 507 | + | } | |
| 508 | + | activeIdx = idx | |
| 509 | + | switch block.Type { | |
| 510 | + | case "tool_use": | |
| 511 | + | name := block.Name | |
| 512 | + | if c.oauthTok != "" { | |
| 513 | + | name = fromClaudeCodeToolName(name, req.Tools) | |
| 514 | + | } | |
| 515 | + | be := registerBlock(idx, "tool_use") | |
| 516 | + | be.toolCall = ToolCallBlock{ID: block.ID, Name: name} | |
| 517 | + | out <- EventToolStart{ID: block.ID, Name: name} | |
| 518 | + | case "text": | |
| 519 | + | registerBlock(idx, "text") | |
| 520 | + | case "thinking": | |
| 521 | + | // not surfaced | |
| 522 | + | } | |
| 523 | + | case "content_block_delta": | |
| 524 | + | var idx int | |
| 525 | + | if b, ok := payload["index"]; ok { | |
| 526 | + | _ = json.Unmarshal(b, &idx) | |
| 527 | + | } | |
| 528 | + | var d struct { | |
| 529 | + | Type string `json:"type"` | |
| 530 | + | Text string `json:"text"` | |
| 531 | + | PartialJSON string `json:"partial_json"` | |
| 532 | + | Thinking string `json:"thinking"` | |
| 533 | + | } | |
| 534 | + | if b, ok := payload["delta"]; ok { | |
| 535 | + | _ = json.Unmarshal(b, &d) | |
| 536 | + | } | |
| 537 | + | switch d.Type { | |
| 538 | + | case "text_delta": | |
| 539 | + | if be, ok := blocks[idx]; ok && be.kind == "text" { | |
| 540 | + | be.textBuf.WriteString(d.Text) | |
| 541 | + | } | |
| 542 | + | out <- EventTextDelta{Delta: d.Text} | |
| 543 | + | case "input_json_delta": | |
| 544 | + | if be, ok := blocks[idx]; ok && be.kind == "tool_use" { | |
| 545 | + | be.toolArgs.WriteString(d.PartialJSON) | |
| 546 | + | out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON} | |
| 547 | + | } | |
| 548 | + | case "thinking_delta": | |
| 549 | + | // Not surfaced in v1. | |
| 550 | + | } | |
| 551 | + | case "content_block_stop": | |
| 552 | + | var idx int | |
| 553 | + | if b, ok := payload["index"]; ok { | |
| 554 | + | _ = json.Unmarshal(b, &idx) | |
| 555 | + | } | |
| 556 | + | if be, ok := blocks[idx]; ok && be.kind == "tool_use" { | |
| 557 | + | out <- EventToolEnd{ID: be.toolCall.ID} | |
| 558 | + | } | |
| 559 | + | activeIdx = -1 | |
| 560 | + | case "message_start": | |
| 561 | + | var m struct { | |
| 562 | + | Message struct { | |
| 563 | + | Usage struct { | |
| 564 | + | InputTokens int `json:"input_tokens"` | |
| 565 | + | OutputTokens int `json:"output_tokens"` | |
| 566 | + | CacheReadInputTokens int `json:"cache_read_input_tokens"` | |
| 567 | + | CacheCreationInputTokens int `json:"cache_creation_input_tokens"` | |
| 568 | + | } `json:"usage"` | |
| 569 | + | } `json:"message"` | |
| 570 | + | } | |
| 571 | + | _ = json.Unmarshal([]byte(ev.Data), &m) | |
| 572 | + | usage.InputTokens += m.Message.Usage.InputTokens | |
| 573 | + | usage.OutputTokens += m.Message.Usage.OutputTokens | |
| 574 | + | usage.CacheReadTokens += m.Message.Usage.CacheReadInputTokens | |
| 575 | + | usage.CacheWriteTokens += m.Message.Usage.CacheCreationInputTokens | |
| 576 | + | case "message_delta": | |
| 577 | + | var m struct { | |
| 578 | + | Delta struct { | |
| 579 | + | StopReason string `json:"stop_reason"` | |
| 580 | + | } `json:"delta"` | |
| 581 | + | Usage struct { | |
| 582 | + | InputTokens int `json:"input_tokens"` | |
| 583 | + | OutputTokens int `json:"output_tokens"` | |
| 584 | + | CacheReadInputTokens int `json:"cache_read_input_tokens"` | |
| 585 | + | CacheCreationInputTokens int `json:"cache_creation_input_tokens"` | |
| 586 | + | } `json:"usage"` | |
| 587 | + | } | |
| 588 | + | _ = json.Unmarshal([]byte(ev.Data), &m) | |
| 589 | + | usage.InputTokens += m.Usage.InputTokens | |
| 590 | + | usage.OutputTokens += m.Usage.OutputTokens | |
| 591 | + | usage.CacheReadTokens += m.Usage.CacheReadInputTokens | |
| 592 | + | usage.CacheWriteTokens += m.Usage.CacheCreationInputTokens | |
| 593 | + | switch m.Delta.StopReason { | |
| 594 | + | case "end_turn", "stop_sequence": | |
| 595 | + | stop = StopEnd | |
| 596 | + | case "max_tokens": | |
| 597 | + | stop = StopLength | |
| 598 | + | case "tool_use": | |
| 599 | + | stop = StopToolUse | |
| 600 | + | } | |
| 601 | + | case "message_stop": | |
| 602 | + | sendDone() | |
| 603 | + | return | |
| 604 | + | case "error": | |
| 605 | + | var e struct { | |
| 606 | + | Error struct { | |
| 607 | + | Type string `json:"type"` | |
| 608 | + | Message string `json:"message"` | |
| 609 | + | } `json:"error"` | |
| 610 | + | } | |
| 611 | + | _ = json.Unmarshal([]byte(ev.Data), &e) | |
| 612 | + | stop = StopError | |
| 613 | + | finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message) | |
| 614 | + | sendDone() | |
| 615 | + | return | |
| 616 | + | } | |
| 617 | + | _ = activeIdx | |
| 618 | + | } | |
| 619 | + | } | |
| 620 | + | } | |
A internal/provider/cache.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ModelCache is the on-disk shape for discovered models. | |
| 12 | + | type ModelCache struct { | |
| 13 | + | FetchedAt time.Time `json:"fetched_at"` | |
| 14 | + | Models []Model `json:"models"` | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // CacheTTL is how long a discovered list is considered fresh. | |
| 18 | + | const CacheTTL = 6 * time.Hour | |
| 19 | + | ||
| 20 | + | // LoadCache reads the model cache from path. Returns an empty ModelCache | |
| 21 | + | // (no error) if the file does not exist. | |
| 22 | + | func LoadCache(path string) (ModelCache, error) { | |
| 23 | + | var c ModelCache | |
| 24 | + | b, err := os.ReadFile(path) | |
| 25 | + | if errors.Is(err, os.ErrNotExist) { | |
| 26 | + | return c, nil | |
| 27 | + | } | |
| 28 | + | if err != nil { | |
| 29 | + | return c, err | |
| 30 | + | } | |
| 31 | + | if err := json.Unmarshal(b, &c); err != nil { | |
| 32 | + | return c, err | |
| 33 | + | } | |
| 34 | + | for i := range c.Models { | |
| 35 | + | if c.Models[i].Source == "" { | |
| 36 | + | c.Models[i].Source = "cache" | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | return c, nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // SaveCache writes the cache atomically. | |
| 43 | + | func SaveCache(path string, c ModelCache) error { | |
| 44 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | b, err := json.MarshalIndent(c, "", " ") | |
| 48 | + | if err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | tmp := path + ".tmp" | |
| 52 | + | if err := os.WriteFile(tmp, b, 0o644); err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | return os.Rename(tmp, path) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // IsFresh reports whether the cache was fetched within CacheTTL. | |
| 59 | + | func (c ModelCache) IsFresh() bool { | |
| 60 | + | if c.FetchedAt.IsZero() { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | return time.Since(c.FetchedAt) < CacheTTL | |
| 64 | + | } | |
A internal/provider/discover.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // DiscoverAnthropic lists model ids visible to key on api.anthropic.com. | |
| 14 | + | // The API returns a paginated list; we page through until has_more is false. | |
| 15 | + | func DiscoverAnthropic(ctx context.Context, apiKey, baseURL string) ([]Model, error) { | |
| 16 | + | if baseURL == "" { | |
| 17 | + | baseURL = anthropicDefaultBaseURL | |
| 18 | + | } | |
| 19 | + | client := &http.Client{Timeout: 15 * time.Second} | |
| 20 | + | var out []Model | |
| 21 | + | after := "" | |
| 22 | + | for { | |
| 23 | + | url := strings.TrimRight(baseURL, "/") + "/v1/models?limit=1000" | |
| 24 | + | if after != "" { | |
| 25 | + | url += "&after_id=" + after | |
| 26 | + | } | |
| 27 | + | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 28 | + | if err != nil { | |
| 29 | + | return nil, err | |
| 30 | + | } | |
| 31 | + | req.Header.Set("x-api-key", apiKey) | |
| 32 | + | req.Header.Set("anthropic-version", anthropicAPIVersion) | |
| 33 | + | resp, err := client.Do(req) | |
| 34 | + | if err != nil { | |
| 35 | + | return nil, err | |
| 36 | + | } | |
| 37 | + | body, _ := io.ReadAll(resp.Body) | |
| 38 | + | resp.Body.Close() | |
| 39 | + | if resp.StatusCode != 200 { | |
| 40 | + | return nil, fmt.Errorf("anthropic discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |
| 41 | + | } | |
| 42 | + | var page struct { | |
| 43 | + | Data []struct { | |
| 44 | + | ID string `json:"id"` | |
| 45 | + | DisplayName string `json:"display_name"` | |
| 46 | + | } `json:"data"` | |
| 47 | + | HasMore bool `json:"has_more"` | |
| 48 | + | LastID string `json:"last_id"` | |
| 49 | + | } | |
| 50 | + | if err := json.Unmarshal(body, &page); err != nil { | |
| 51 | + | return nil, fmt.Errorf("anthropic discover parse: %w", err) | |
| 52 | + | } | |
| 53 | + | for _, d := range page.Data { | |
| 54 | + | out = append(out, Model{ | |
| 55 | + | Provider: "anthropic", | |
| 56 | + | ID: d.ID, | |
| 57 | + | DisplayName: d.DisplayName, | |
| 58 | + | Source: "live", | |
| 59 | + | }) | |
| 60 | + | } | |
| 61 | + | if !page.HasMore || page.LastID == "" { | |
| 62 | + | break | |
| 63 | + | } | |
| 64 | + | after = page.LastID | |
| 65 | + | } | |
| 66 | + | return out, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // DiscoverOpenAI lists model ids visible to key on api.openai.com. | |
| 70 | + | func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error) { | |
| 71 | + | if baseURL == "" { | |
| 72 | + | baseURL = openaiDefaultBaseURL | |
| 73 | + | } | |
| 74 | + | client := &http.Client{Timeout: 15 * time.Second} | |
| 75 | + | url := strings.TrimRight(baseURL, "/") + "/v1/models" | |
| 76 | + | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 77 | + | if err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | req.Header.Set("authorization", "Bearer "+apiKey) | |
| 81 | + | resp, err := client.Do(req) | |
| 82 | + | if err != nil { | |
| 83 | + | return nil, err | |
| 84 | + | } | |
| 85 | + | defer resp.Body.Close() | |
| 86 | + | body, _ := io.ReadAll(resp.Body) | |
| 87 | + | if resp.StatusCode != 200 { | |
| 88 | + | return nil, fmt.Errorf("openai discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |
| 89 | + | } | |
| 90 | + | var page struct { | |
| 91 | + | Data []struct { | |
| 92 | + | ID string `json:"id"` | |
| 93 | + | } `json:"data"` | |
| 94 | + | } | |
| 95 | + | if err := json.Unmarshal(body, &page); err != nil { | |
| 96 | + | return nil, fmt.Errorf("openai discover parse: %w", err) | |
| 97 | + | } | |
| 98 | + | var out []Model | |
| 99 | + | for _, d := range page.Data { | |
| 100 | + | // Keep only chat-capable families. OpenAI's /v1/models returns | |
| 101 | + | // everything including embeddings, TTS, DALL-E, etc. | |
| 102 | + | if !looksLikeChatModel(d.ID) { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | out = append(out, Model{ | |
| 106 | + | Provider: "openai", | |
| 107 | + | ID: d.ID, | |
| 108 | + | DisplayName: d.ID, | |
| 109 | + | Source: "live", | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | return out, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // looksLikeChatModel returns true for OpenAI ids that can plausibly be | |
| 116 | + | // used with the chat/completions endpoint. Errs on the side of inclusion. | |
| 117 | + | func looksLikeChatModel(id string) bool { | |
| 118 | + | switch { | |
| 119 | + | case strings.HasPrefix(id, "gpt-"): | |
| 120 | + | return true | |
| 121 | + | case strings.HasPrefix(id, "o1"): | |
| 122 | + | return true | |
| 123 | + | case strings.HasPrefix(id, "o3"): | |
| 124 | + | return true | |
| 125 | + | case strings.HasPrefix(id, "o4"): | |
| 126 | + | return true | |
| 127 | + | case strings.HasPrefix(id, "o5"): | |
| 128 | + | return true | |
| 129 | + | case strings.HasPrefix(id, "chatgpt-"): | |
| 130 | + | return true | |
| 131 | + | } | |
| 132 | + | return false | |
| 133 | + | } | |
A internal/provider/merge.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | // MergeCatalog returns the baked-in catalog overlaid with live entries. | |
| 4 | + | // Precedence per id: live > catalog; speculative entries are promoted | |
| 5 | + | // to non-speculative when a matching live id appears. | |
| 6 | + | // | |
| 7 | + | // Unknown live ids (not in the static catalog) are appended at the end | |
| 8 | + | // with placeholder prices so they still render in the picker. Prices | |
| 9 | + | // can be populated later from a richer catalog source. | |
| 10 | + | func MergeCatalog(live []Model) []Model { | |
| 11 | + | byKey := func(p, id string) string { return p + "/" + id } | |
| 12 | + | ||
| 13 | + | staticIndex := make(map[string]Model, len(Catalog)) | |
| 14 | + | staticOrder := make([]string, 0, len(Catalog)) | |
| 15 | + | for _, m := range Catalog { | |
| 16 | + | m.Source = "catalog" | |
| 17 | + | k := byKey(m.Provider, m.ID) | |
| 18 | + | staticIndex[k] = m | |
| 19 | + | staticOrder = append(staticOrder, k) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | seenLive := make(map[string]bool, len(live)) | |
| 23 | + | for _, m := range live { | |
| 24 | + | seenLive[byKey(m.Provider, m.ID)] = true | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Promote/overwrite from live. | |
| 28 | + | for _, l := range live { | |
| 29 | + | k := byKey(l.Provider, l.ID) | |
| 30 | + | if s, ok := staticIndex[k]; ok { | |
| 31 | + | // Keep static prices & context window (live endpoint rarely | |
| 32 | + | // exposes these), but mark as live and non-speculative. | |
| 33 | + | s.Source = "live" | |
| 34 | + | s.Speculative = false | |
| 35 | + | if l.DisplayName != "" { | |
| 36 | + | s.DisplayName = l.DisplayName | |
| 37 | + | } | |
| 38 | + | staticIndex[k] = s | |
| 39 | + | } else { | |
| 40 | + | // New live id we'd never heard of. Best-effort defaults. | |
| 41 | + | if l.DisplayName == "" { | |
| 42 | + | l.DisplayName = l.ID | |
| 43 | + | } | |
| 44 | + | staticIndex[k] = l | |
| 45 | + | staticOrder = append(staticOrder, k) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | out := make([]Model, 0, len(staticOrder)) | |
| 50 | + | for _, k := range staticOrder { | |
| 51 | + | out = append(out, staticIndex[k]) | |
| 52 | + | } | |
| 53 | + | return out | |
| 54 | + | } | |
A internal/provider/models.go +283 -0
| @@ -0,0 +1,283 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Model describes a single LLM we know about. | |
| 9 | + | type Model struct { | |
| 10 | + | Provider string // "anthropic" | "openai" | |
| 11 | + | ID string // API id | |
| 12 | + | DisplayName string | |
| 13 | + | ContextWindow int | |
| 14 | + | MaxOutput int | |
| 15 | + | Reasoning bool // supports reasoning/thinking | |
| 16 | + | ||
| 17 | + | // Prices are USD per 1M tokens. | |
| 18 | + | PriceInput float64 | |
| 19 | + | PriceOutput float64 | |
| 20 | + | PriceCacheRead float64 | |
| 21 | + | PriceCacheWrite float64 | |
| 22 | + | ||
| 23 | + | // Speculative marks models whose ids are known from the upstream | |
| 24 | + | // vendor's CLI but not yet live on their public API. They'll 404 | |
| 25 | + | // today but start working the moment the provider flips the switch. | |
| 26 | + | Speculative bool | |
| 27 | + | ||
| 28 | + | // Source is where this model entry came from: "catalog" (baked in), | |
| 29 | + | // "live" (discovered via /v1/models), or "cache" (loaded from the | |
| 30 | + | // on-disk cache). Informational. | |
| 31 | + | Source string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Catalog is the hardcoded, read-only list of supported models. | |
| 35 | + | // Prices are USD per 1M tokens. The list is curated to what zot's | |
| 36 | + | // clients (Anthropic Messages + OpenAI Chat Completions) can actually | |
| 37 | + | // talk to; models that are only reachable through the OpenAI Responses | |
| 38 | + | // API (o1-pro, o3-pro, gpt-5-pro) are omitted. | |
| 39 | + | var Catalog = []Model{ | |
| 40 | + | // ---- Anthropic / Claude 4.x ---- | |
| 41 | + | { | |
| 42 | + | Provider: "anthropic", ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", | |
| 43 | + | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | |
| 44 | + | PriceInput: 3.00, PriceOutput: 15.00, PriceCacheRead: 0.30, PriceCacheWrite: 3.75, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | Provider: "anthropic", ID: "claude-opus-4-1", DisplayName: "Claude Opus 4.1", | |
| 48 | + | ContextWindow: 200000, MaxOutput: 32000, Reasoning: true, | |
| 49 | + | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | Provider: "anthropic", ID: "claude-opus-4-0", DisplayName: "Claude Opus 4", | |
| 53 | + | ContextWindow: 200000, MaxOutput: 32000, Reasoning: true, | |
| 54 | + | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | Provider: "anthropic", ID: "claude-sonnet-4-0", DisplayName: "Claude Sonnet 4", | |
| 58 | + | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | |
| 59 | + | PriceInput: 3.00, PriceOutput: 15.00, PriceCacheRead: 0.30, PriceCacheWrite: 3.75, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | Provider: "anthropic", ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", | |
| 63 | + | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | |
| 64 | + | PriceInput: 1.00, PriceOutput: 5.00, PriceCacheRead: 0.10, PriceCacheWrite: 1.25, | |
| 65 | + | }, | |
| 66 | + | ||
| 67 | + | // ---- Anthropic / Claude 3.x (legacy) ---- | |
| 68 | + | { | |
| 69 | + | Provider: "anthropic", ID: "claude-3-7-sonnet-20250219", DisplayName: "Claude Sonnet 3.7", | |
| 70 | + | ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, | |
| 71 | + | PriceInput: 3.00, PriceOutput: 15.00, PriceCacheRead: 0.30, PriceCacheWrite: 3.75, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | Provider: "anthropic", ID: "claude-3-5-sonnet-20241022", DisplayName: "Claude Sonnet 3.5 v2", | |
| 75 | + | ContextWindow: 200000, MaxOutput: 8192, Reasoning: false, | |
| 76 | + | PriceInput: 3.00, PriceOutput: 15.00, PriceCacheRead: 0.30, PriceCacheWrite: 3.75, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | Provider: "anthropic", ID: "claude-3-5-haiku-latest", DisplayName: "Claude Haiku 3.5", | |
| 80 | + | ContextWindow: 200000, MaxOutput: 8192, Reasoning: false, | |
| 81 | + | PriceInput: 0.80, PriceOutput: 4.00, PriceCacheRead: 0.08, PriceCacheWrite: 1.00, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | Provider: "anthropic", ID: "claude-3-opus-20240229", DisplayName: "Claude Opus 3", | |
| 85 | + | ContextWindow: 200000, MaxOutput: 4096, Reasoning: false, | |
| 86 | + | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | |
| 87 | + | }, | |
| 88 | + | ||
| 89 | + | // ---- OpenAI / GPT-5 family ---- | |
| 90 | + | { | |
| 91 | + | Provider: "openai", ID: "gpt-5", DisplayName: "GPT-5", | |
| 92 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 93 | + | PriceInput: 1.25, PriceOutput: 10.00, PriceCacheRead: 0.125, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | Provider: "openai", ID: "gpt-5-mini", DisplayName: "GPT-5 mini", | |
| 97 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 98 | + | PriceInput: 0.25, PriceOutput: 2.00, PriceCacheRead: 0.025, | |
| 99 | + | }, | |
| 100 | + | { | |
| 101 | + | Provider: "openai", ID: "gpt-5-nano", DisplayName: "GPT-5 nano", | |
| 102 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 103 | + | PriceInput: 0.05, PriceOutput: 0.40, PriceCacheRead: 0.005, | |
| 104 | + | }, | |
| 105 | + | ||
| 106 | + | // ---- OpenAI / GPT-4.1 family ---- | |
| 107 | + | { | |
| 108 | + | Provider: "openai", ID: "gpt-4.1", DisplayName: "GPT-4.1", | |
| 109 | + | ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false, | |
| 110 | + | PriceInput: 2.00, PriceOutput: 8.00, PriceCacheRead: 0.50, | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | Provider: "openai", ID: "gpt-4.1-mini", DisplayName: "GPT-4.1 mini", | |
| 114 | + | ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false, | |
| 115 | + | PriceInput: 0.40, PriceOutput: 1.60, PriceCacheRead: 0.10, | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | Provider: "openai", ID: "gpt-4.1-nano", DisplayName: "GPT-4.1 nano", | |
| 119 | + | ContextWindow: 1047576, MaxOutput: 32768, Reasoning: false, | |
| 120 | + | PriceInput: 0.10, PriceOutput: 0.40, PriceCacheRead: 0.03, | |
| 121 | + | }, | |
| 122 | + | ||
| 123 | + | // ---- OpenAI / GPT-4o family ---- | |
| 124 | + | { | |
| 125 | + | Provider: "openai", ID: "gpt-4o", DisplayName: "GPT-4o", | |
| 126 | + | ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, | |
| 127 | + | PriceInput: 2.50, PriceOutput: 10.00, PriceCacheRead: 1.25, | |
| 128 | + | }, | |
| 129 | + | { | |
| 130 | + | Provider: "openai", ID: "gpt-4o-mini", DisplayName: "GPT-4o mini", | |
| 131 | + | ContextWindow: 128000, MaxOutput: 16384, Reasoning: false, | |
| 132 | + | PriceInput: 0.15, PriceOutput: 0.60, PriceCacheRead: 0.08, | |
| 133 | + | }, | |
| 134 | + | ||
| 135 | + | // ---- OpenAI / reasoning models ---- | |
| 136 | + | { | |
| 137 | + | Provider: "openai", ID: "o4-mini", DisplayName: "o4-mini", | |
| 138 | + | ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, | |
| 139 | + | PriceInput: 1.10, PriceOutput: 4.40, PriceCacheRead: 0.275, | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | Provider: "openai", ID: "o3", DisplayName: "o3", | |
| 143 | + | ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, | |
| 144 | + | PriceInput: 2.00, PriceOutput: 8.00, PriceCacheRead: 0.50, | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | Provider: "openai", ID: "o3-mini", DisplayName: "o3-mini", | |
| 148 | + | ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, | |
| 149 | + | PriceInput: 1.10, PriceOutput: 4.40, PriceCacheRead: 0.55, | |
| 150 | + | }, | |
| 151 | + | { | |
| 152 | + | Provider: "openai", ID: "o1", DisplayName: "o1", | |
| 153 | + | ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, | |
| 154 | + | PriceInput: 15.00, PriceOutput: 60.00, PriceCacheRead: 7.50, | |
| 155 | + | }, | |
| 156 | + | ||
| 157 | + | // ---- Speculative: Anthropic ---- | |
| 158 | + | { | |
| 159 | + | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5", | |
| 160 | + | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, | |
| 161 | + | PriceInput: 5.00, PriceOutput: 25.00, PriceCacheRead: 0.50, PriceCacheWrite: 6.25, | |
| 162 | + | Speculative: true, | |
| 163 | + | }, | |
| 164 | + | { | |
| 165 | + | Provider: "anthropic", ID: "claude-opus-4-6", DisplayName: "Claude Opus 4.6", | |
| 166 | + | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, | |
| 167 | + | PriceInput: 5.00, PriceOutput: 25.00, PriceCacheRead: 0.50, PriceCacheWrite: 6.25, | |
| 168 | + | Speculative: true, | |
| 169 | + | }, | |
| 170 | + | { | |
| 171 | + | Provider: "anthropic", ID: "claude-opus-4-7", DisplayName: "Claude Opus 4.7", | |
| 172 | + | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, | |
| 173 | + | PriceInput: 5.00, PriceOutput: 25.00, PriceCacheRead: 0.50, PriceCacheWrite: 6.25, | |
| 174 | + | Speculative: true, | |
| 175 | + | }, | |
| 176 | + | { | |
| 177 | + | Provider: "anthropic", ID: "claude-sonnet-4-6", DisplayName: "Claude Sonnet 4.6", | |
| 178 | + | ContextWindow: 1000000, MaxOutput: 64000, Reasoning: true, | |
| 179 | + | PriceInput: 3.00, PriceOutput: 15.00, PriceCacheRead: 0.30, PriceCacheWrite: 3.75, | |
| 180 | + | Speculative: true, | |
| 181 | + | }, | |
| 182 | + | ||
| 183 | + | // ---- Speculative: OpenAI ---- | |
| 184 | + | { | |
| 185 | + | Provider: "openai", ID: "gpt-5.1", DisplayName: "GPT-5.1", | |
| 186 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 187 | + | PriceInput: 1.25, PriceOutput: 10.00, PriceCacheRead: 0.125, | |
| 188 | + | Speculative: true, | |
| 189 | + | }, | |
| 190 | + | { | |
| 191 | + | Provider: "openai", ID: "gpt-5.2", DisplayName: "GPT-5.2", | |
| 192 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 193 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |
| 194 | + | Speculative: true, | |
| 195 | + | }, | |
| 196 | + | { | |
| 197 | + | Provider: "openai", ID: "gpt-5.3", DisplayName: "GPT-5.3", | |
| 198 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 199 | + | PriceInput: 1.75, PriceOutput: 14.00, PriceCacheRead: 0.175, | |
| 200 | + | Speculative: true, | |
| 201 | + | }, | |
| 202 | + | { | |
| 203 | + | Provider: "openai", ID: "gpt-5.4", DisplayName: "GPT-5.4", | |
| 204 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 205 | + | PriceInput: 2.50, PriceOutput: 15.00, PriceCacheRead: 0.25, | |
| 206 | + | Speculative: true, | |
| 207 | + | }, | |
| 208 | + | { | |
| 209 | + | Provider: "openai", ID: "gpt-5.4-mini", DisplayName: "GPT-5.4 mini", | |
| 210 | + | ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, | |
| 211 | + | PriceInput: 0.75, PriceOutput: 4.50, PriceCacheRead: 0.075, | |
| 212 | + | Speculative: true, | |
| 213 | + | }, | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // DefaultModel is used when the user does not specify one. | |
| 217 | + | var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |
| 218 | + | ||
| 219 | + | // ----- active (merged) catalog ----- | |
| 220 | + | // | |
| 221 | + | // Callers should use Active() / FindModel / ModelsForProvider for | |
| 222 | + | // lookups. They return the baked-in Catalog merged with any live | |
| 223 | + | // models loaded via SetLiveModels. | |
| 224 | + | ||
| 225 | + | var ( | |
| 226 | + | activeMu sync.RWMutex | |
| 227 | + | active []Model = Catalog // default: just the static catalog | |
| 228 | + | ) | |
| 229 | + | ||
| 230 | + | // SetLiveModels replaces the "live" overlay used by the active catalog. | |
| 231 | + | // Typically called after a successful /v1/models discovery or on load | |
| 232 | + | // from the on-disk cache. | |
| 233 | + | func SetLiveModels(live []Model) { | |
| 234 | + | activeMu.Lock() | |
| 235 | + | defer activeMu.Unlock() | |
| 236 | + | if len(live) == 0 { | |
| 237 | + | active = Catalog | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | active = MergeCatalog(live) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Active returns the current merged catalog. | |
| 244 | + | func Active() []Model { | |
| 245 | + | activeMu.RLock() | |
| 246 | + | defer activeMu.RUnlock() | |
| 247 | + | out := make([]Model, len(active)) | |
| 248 | + | copy(out, active) | |
| 249 | + | return out | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // FindModel returns a Model by id, optionally constrained by provider. | |
| 253 | + | // If provider is empty, the first matching id is returned. Looks up | |
| 254 | + | // against the merged active catalog. | |
| 255 | + | func FindModel(provider, id string) (Model, error) { | |
| 256 | + | for _, m := range Active() { | |
| 257 | + | if m.ID == id && (provider == "" || m.Provider == provider) { | |
| 258 | + | return m, nil | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | return Model{}, fmt.Errorf("unknown model %q (provider=%q)", id, provider) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // ModelsForProvider returns all models for the given provider, from the | |
| 265 | + | // merged active catalog. | |
| 266 | + | func ModelsForProvider(provider string) []Model { | |
| 267 | + | var out []Model | |
| 268 | + | for _, m := range Active() { | |
| 269 | + | if m.Provider == provider { | |
| 270 | + | out = append(out, m) | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | return out | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // ComputeCost returns the USD cost for the given usage on model m. | |
| 277 | + | func ComputeCost(m Model, u Usage) float64 { | |
| 278 | + | const per = 1_000_000.0 | |
| 279 | + | return float64(u.InputTokens)*m.PriceInput/per + | |
| 280 | + | float64(u.OutputTokens)*m.PriceOutput/per + | |
| 281 | + | float64(u.CacheReadTokens)*m.PriceCacheRead/per + | |
| 282 | + | float64(u.CacheWriteTokens)*m.PriceCacheWrite/per | |
| 283 | + | } | |
A internal/provider/openai.go +468 -0
| @@ -0,0 +1,468 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const openaiDefaultBaseURL = "https://api.openai.com" | |
| 16 | + | ||
| 17 | + | type openaiClient struct { | |
| 18 | + | apiKey string | |
| 19 | + | baseURL string | |
| 20 | + | oauth bool // when true, apiKey actually holds an OAuth access token | |
| 21 | + | http *http.Client | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewOpenAI creates an OpenAI client using an API key. baseURL may be empty. | |
| 25 | + | func NewOpenAI(apiKey, baseURL string) Client { | |
| 26 | + | if baseURL == "" { | |
| 27 | + | baseURL = openaiDefaultBaseURL | |
| 28 | + | } | |
| 29 | + | return &openaiClient{ | |
| 30 | + | apiKey: apiKey, | |
| 31 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 32 | + | http: &http.Client{Timeout: 0}, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // NewOpenAIOAuth creates an OpenAI client using a subscription OAuth access token. | |
| 37 | + | // The token is sent as an HTTP Bearer credential on the standard chat/completions endpoint. | |
| 38 | + | func NewOpenAIOAuth(accessToken, baseURL string) Client { | |
| 39 | + | if baseURL == "" { | |
| 40 | + | baseURL = openaiDefaultBaseURL | |
| 41 | + | } | |
| 42 | + | return &openaiClient{ | |
| 43 | + | apiKey: accessToken, | |
| 44 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 45 | + | oauth: true, | |
| 46 | + | http: &http.Client{Timeout: 0}, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (c *openaiClient) Name() string { return "openai" } | |
| 51 | + | ||
| 52 | + | // ---- wire types ---- | |
| 53 | + | ||
| 54 | + | type oaiContentText struct { | |
| 55 | + | Type string `json:"type"` // "text" | |
| 56 | + | Text string `json:"text"` | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type oaiContentImage struct { | |
| 60 | + | Type string `json:"type"` // "image_url" | |
| 61 | + | ImageURL struct { | |
| 62 | + | URL string `json:"url"` | |
| 63 | + | } `json:"image_url"` | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | type oaiToolCallFn struct { | |
| 67 | + | Name string `json:"name"` | |
| 68 | + | Arguments string `json:"arguments"` // JSON string | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type oaiToolCall struct { | |
| 72 | + | ID string `json:"id"` | |
| 73 | + | Type string `json:"type"` // "function" | |
| 74 | + | Function oaiToolCallFn `json:"function"` | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | type oaiMessage struct { | |
| 78 | + | Role string `json:"role"` | |
| 79 | + | Content interface{} `json:"content,omitempty"` // string or []block | |
| 80 | + | Name string `json:"name,omitempty"` | |
| 81 | + | ToolCalls []oaiToolCall `json:"tool_calls,omitempty"` | |
| 82 | + | ToolCallID string `json:"tool_call_id,omitempty"` | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | type oaiTool struct { | |
| 86 | + | Type string `json:"type"` // "function" | |
| 87 | + | Function struct { | |
| 88 | + | Name string `json:"name"` | |
| 89 | + | Description string `json:"description,omitempty"` | |
| 90 | + | Parameters json.RawMessage `json:"parameters"` | |
| 91 | + | } `json:"function"` | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | type oaiStreamOptions struct { | |
| 95 | + | IncludeUsage bool `json:"include_usage"` | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | type oaiRequest struct { | |
| 99 | + | Model string `json:"model"` | |
| 100 | + | Messages []oaiMessage `json:"messages"` | |
| 101 | + | Tools []oaiTool `json:"tools,omitempty"` | |
| 102 | + | ToolChoice string `json:"tool_choice,omitempty"` | |
| 103 | + | Stream bool `json:"stream"` | |
| 104 | + | StreamOptions *oaiStreamOptions `json:"stream_options,omitempty"` | |
| 105 | + | Temperature *float32 `json:"temperature,omitempty"` | |
| 106 | + | MaxTokens *int `json:"max_tokens,omitempty"` | |
| 107 | + | MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` | |
| 108 | + | ReasoningEffort string `json:"reasoning_effort,omitempty"` | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // ---- request building ---- | |
| 112 | + | ||
| 113 | + | func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |
| 114 | + | m, err := FindModel("openai", req.Model) | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, err | |
| 117 | + | } | |
| 118 | + | out := &oaiRequest{ | |
| 119 | + | Model: req.Model, | |
| 120 | + | Stream: true, | |
| 121 | + | StreamOptions: &oaiStreamOptions{IncludeUsage: true}, | |
| 122 | + | Temperature: req.Temperature, | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | maxTok := req.MaxTokens | |
| 126 | + | if maxTok <= 0 { | |
| 127 | + | maxTok = m.MaxOutput | |
| 128 | + | } | |
| 129 | + | if m.Reasoning { | |
| 130 | + | out.MaxCompletionTok = &maxTok | |
| 131 | + | if req.Reasoning != "" { | |
| 132 | + | out.ReasoningEffort = strings.ToLower(req.Reasoning) | |
| 133 | + | } | |
| 134 | + | } else { | |
| 135 | + | out.MaxTokens = &maxTok | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | if req.System != "" { | |
| 139 | + | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System}) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | for _, msg := range req.Messages { | |
| 143 | + | switch msg.Role { | |
| 144 | + | case RoleUser: | |
| 145 | + | content := buildOAIUserContent(msg.Content) | |
| 146 | + | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | |
| 147 | + | case RoleAssistant: | |
| 148 | + | am := oaiMessage{Role: "assistant"} | |
| 149 | + | var text strings.Builder | |
| 150 | + | for _, b := range msg.Content { | |
| 151 | + | switch v := b.(type) { | |
| 152 | + | case TextBlock: | |
| 153 | + | if text.Len() > 0 { | |
| 154 | + | text.WriteString("\n") | |
| 155 | + | } | |
| 156 | + | text.WriteString(v.Text) | |
| 157 | + | case ToolCallBlock: | |
| 158 | + | args := v.Arguments | |
| 159 | + | if len(args) == 0 { | |
| 160 | + | args = json.RawMessage("{}") | |
| 161 | + | } | |
| 162 | + | am.ToolCalls = append(am.ToolCalls, oaiToolCall{ | |
| 163 | + | ID: v.ID, | |
| 164 | + | Type: "function", | |
| 165 | + | Function: oaiToolCallFn{ | |
| 166 | + | Name: v.Name, | |
| 167 | + | Arguments: string(args), | |
| 168 | + | }, | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | if text.Len() > 0 { | |
| 173 | + | am.Content = text.String() | |
| 174 | + | } | |
| 175 | + | out.Messages = append(out.Messages, am) | |
| 176 | + | case RoleTool: | |
| 177 | + | // Each ToolResultBlock becomes its own tool message. | |
| 178 | + | for _, b := range msg.Content { | |
| 179 | + | if tr, ok := b.(ToolResultBlock); ok { | |
| 180 | + | var text strings.Builder | |
| 181 | + | for _, inner := range tr.Content { | |
| 182 | + | if tb, ok := inner.(TextBlock); ok { | |
| 183 | + | if text.Len() > 0 { | |
| 184 | + | text.WriteString("\n") | |
| 185 | + | } | |
| 186 | + | text.WriteString(tb.Text) | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | if tr.IsError && text.Len() > 0 { | |
| 190 | + | text.WriteString(" [error]") | |
| 191 | + | } | |
| 192 | + | out.Messages = append(out.Messages, oaiMessage{ | |
| 193 | + | Role: "tool", | |
| 194 | + | ToolCallID: tr.CallID, | |
| 195 | + | Content: text.String(), | |
| 196 | + | }) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | for _, t := range req.Tools { | |
| 203 | + | var tool oaiTool | |
| 204 | + | tool.Type = "function" | |
| 205 | + | tool.Function.Name = t.Name | |
| 206 | + | tool.Function.Description = t.Description | |
| 207 | + | tool.Function.Parameters = t.Schema | |
| 208 | + | out.Tools = append(out.Tools, tool) | |
| 209 | + | } | |
| 210 | + | if len(out.Tools) > 0 { | |
| 211 | + | out.ToolChoice = "auto" | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | return out, nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func buildOAIUserContent(blocks []Content) interface{} { | |
| 218 | + | hasImage := false | |
| 219 | + | for _, b := range blocks { | |
| 220 | + | if _, ok := b.(ImageBlock); ok { | |
| 221 | + | hasImage = true | |
| 222 | + | break | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if !hasImage { | |
| 226 | + | var sb strings.Builder | |
| 227 | + | for _, b := range blocks { | |
| 228 | + | if tb, ok := b.(TextBlock); ok { | |
| 229 | + | if sb.Len() > 0 { | |
| 230 | + | sb.WriteString("\n") | |
| 231 | + | } | |
| 232 | + | sb.WriteString(tb.Text) | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | return sb.String() | |
| 236 | + | } | |
| 237 | + | var arr []interface{} | |
| 238 | + | for _, b := range blocks { | |
| 239 | + | switch v := b.(type) { | |
| 240 | + | case TextBlock: | |
| 241 | + | arr = append(arr, oaiContentText{Type: "text", Text: v.Text}) | |
| 242 | + | case ImageBlock: | |
| 243 | + | var img oaiContentImage | |
| 244 | + | img.Type = "image_url" | |
| 245 | + | img.ImageURL.URL = "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data) | |
| 246 | + | arr = append(arr, img) | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | return arr | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // ---- streaming ---- | |
| 253 | + | ||
| 254 | + | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 255 | + | wire, err := c.buildRequest(req) | |
| 256 | + | if err != nil { | |
| 257 | + | return nil, err | |
| 258 | + | } | |
| 259 | + | body, err := json.Marshal(wire) | |
| 260 | + | if err != nil { | |
| 261 | + | return nil, err | |
| 262 | + | } | |
| 263 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body)) | |
| 264 | + | if err != nil { | |
| 265 | + | return nil, err | |
| 266 | + | } | |
| 267 | + | httpReq.Header.Set("content-type", "application/json") | |
| 268 | + | httpReq.Header.Set("accept", "text/event-stream") | |
| 269 | + | httpReq.Header.Set("authorization", "Bearer "+c.apiKey) | |
| 270 | + | ||
| 271 | + | resp, err := c.http.Do(httpReq) | |
| 272 | + | if err != nil { | |
| 273 | + | return nil, fmt.Errorf("openai: %w", err) | |
| 274 | + | } | |
| 275 | + | if resp.StatusCode != http.StatusOK { | |
| 276 | + | b, _ := io.ReadAll(resp.Body) | |
| 277 | + | resp.Body.Close() | |
| 278 | + | return nil, fmt.Errorf("openai: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | out := make(chan Event, 16) | |
| 282 | + | go c.runStream(ctx, resp, req, out) | |
| 283 | + | return out, nil | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { | |
| 287 | + | defer close(out) | |
| 288 | + | defer resp.Body.Close() | |
| 289 | + | ||
| 290 | + | model, _ := FindModel("openai", req.Model) | |
| 291 | + | out <- EventStart{Model: req.Model, Provider: "openai"} | |
| 292 | + | ||
| 293 | + | raw := make(chan sseEvent, 16) | |
| 294 | + | go readSSE(resp.Body, raw) | |
| 295 | + | ||
| 296 | + | // Interleaved block tracking: text and tool_calls preserve their | |
| 297 | + | // emission order so the assistant message renders in the same order | |
| 298 | + | // the model produced it. The builder fires one kind of block at a | |
| 299 | + | // time — incoming text deltas after a tool_call split into a fresh | |
| 300 | + | // text block; subsequent tool_calls each get their own slot. | |
| 301 | + | type blockEntry struct { | |
| 302 | + | kind string // "text" | "tool_use" | |
| 303 | + | textBuf strings.Builder | |
| 304 | + | toolID string | |
| 305 | + | toolName string | |
| 306 | + | toolArgs strings.Builder | |
| 307 | + | announced bool | |
| 308 | + | } | |
| 309 | + | var ( | |
| 310 | + | blocks []*blockEntry | |
| 311 | + | currentText *blockEntry // most-recent text block, nil if none | |
| 312 | + | toolByIdx = map[int]*blockEntry{} // openai tool_call index -> block | |
| 313 | + | usage Usage | |
| 314 | + | stop StopReason = StopEnd | |
| 315 | + | finalErr error | |
| 316 | + | ) | |
| 317 | + | ||
| 318 | + | appendText := func(delta string) { | |
| 319 | + | if currentText == nil { | |
| 320 | + | currentText = &blockEntry{kind: "text"} | |
| 321 | + | blocks = append(blocks, currentText) | |
| 322 | + | } | |
| 323 | + | currentText.textBuf.WriteString(delta) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | getOrCreateTool := func(idx int) *blockEntry { | |
| 327 | + | if t, ok := toolByIdx[idx]; ok { | |
| 328 | + | return t | |
| 329 | + | } | |
| 330 | + | t := &blockEntry{kind: "tool_use"} | |
| 331 | + | toolByIdx[idx] = t | |
| 332 | + | blocks = append(blocks, t) | |
| 333 | + | // A new tool block breaks the current text block. Subsequent text | |
| 334 | + | // deltas will start a fresh text block after this tool. | |
| 335 | + | currentText = nil | |
| 336 | + | return t | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | assembleMsg := func() Message { | |
| 340 | + | content := []Content{} | |
| 341 | + | for _, b := range blocks { | |
| 342 | + | switch b.kind { | |
| 343 | + | case "text": | |
| 344 | + | if b.textBuf.Len() > 0 { | |
| 345 | + | content = append(content, TextBlock{Text: b.textBuf.String()}) | |
| 346 | + | } | |
| 347 | + | case "tool_use": | |
| 348 | + | args := b.toolArgs.String() | |
| 349 | + | if args == "" { | |
| 350 | + | args = "{}" | |
| 351 | + | } | |
| 352 | + | content = append(content, ToolCallBlock{ | |
| 353 | + | ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), | |
| 354 | + | }) | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return Message{Role: RoleAssistant, Content: content, Time: time.Now()} | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | sendDone := func() { | |
| 361 | + | usage.CostUSD = ComputeCost(model, usage) | |
| 362 | + | out <- EventUsage{Usage: usage} | |
| 363 | + | out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()} | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | for { | |
| 367 | + | select { | |
| 368 | + | case <-ctx.Done(): | |
| 369 | + | stop = StopAborted | |
| 370 | + | finalErr = ctx.Err() | |
| 371 | + | sendDone() | |
| 372 | + | return | |
| 373 | + | case ev, ok := <-raw: | |
| 374 | + | if !ok { | |
| 375 | + | sendDone() | |
| 376 | + | return | |
| 377 | + | } | |
| 378 | + | if ev.Data == "[DONE]" { | |
| 379 | + | sendDone() | |
| 380 | + | return | |
| 381 | + | } | |
| 382 | + | var chunk struct { | |
| 383 | + | Choices []struct { | |
| 384 | + | Index int `json:"index"` | |
| 385 | + | Delta struct { | |
| 386 | + | Content string `json:"content"` | |
| 387 | + | ToolCalls []struct { | |
| 388 | + | Index int `json:"index"` | |
| 389 | + | ID string `json:"id"` | |
| 390 | + | Type string `json:"type"` | |
| 391 | + | Function struct { | |
| 392 | + | Name string `json:"name"` | |
| 393 | + | Arguments string `json:"arguments"` | |
| 394 | + | } `json:"function"` | |
| 395 | + | } `json:"tool_calls"` | |
| 396 | + | } `json:"delta"` | |
| 397 | + | FinishReason string `json:"finish_reason"` | |
| 398 | + | } `json:"choices"` | |
| 399 | + | Usage *struct { | |
| 400 | + | PromptTokens int `json:"prompt_tokens"` | |
| 401 | + | CompletionTokens int `json:"completion_tokens"` | |
| 402 | + | PromptTokensDetails struct { | |
| 403 | + | CachedTokens int `json:"cached_tokens"` | |
| 404 | + | } `json:"prompt_tokens_details"` | |
| 405 | + | } `json:"usage"` | |
| 406 | + | Error *struct { | |
| 407 | + | Message string `json:"message"` | |
| 408 | + | Type string `json:"type"` | |
| 409 | + | } `json:"error"` | |
| 410 | + | } | |
| 411 | + | if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil { | |
| 412 | + | continue | |
| 413 | + | } | |
| 414 | + | if chunk.Error != nil { | |
| 415 | + | stop = StopError | |
| 416 | + | finalErr = fmt.Errorf("openai: %s", chunk.Error.Message) | |
| 417 | + | sendDone() | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | if chunk.Usage != nil { | |
| 421 | + | usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens | |
| 422 | + | if usage.InputTokens < 0 { | |
| 423 | + | usage.InputTokens = chunk.Usage.PromptTokens | |
| 424 | + | } | |
| 425 | + | usage.OutputTokens = chunk.Usage.CompletionTokens | |
| 426 | + | usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens | |
| 427 | + | } | |
| 428 | + | for _, ch := range chunk.Choices { | |
| 429 | + | if ch.Delta.Content != "" { | |
| 430 | + | appendText(ch.Delta.Content) | |
| 431 | + | out <- EventTextDelta{Delta: ch.Delta.Content} | |
| 432 | + | } | |
| 433 | + | for _, tc := range ch.Delta.ToolCalls { | |
| 434 | + | t := getOrCreateTool(tc.Index) | |
| 435 | + | if tc.ID != "" { | |
| 436 | + | t.toolID = tc.ID | |
| 437 | + | } | |
| 438 | + | if tc.Function.Name != "" { | |
| 439 | + | t.toolName = tc.Function.Name | |
| 440 | + | } | |
| 441 | + | if !t.announced && t.toolID != "" && t.toolName != "" { | |
| 442 | + | t.announced = true | |
| 443 | + | out <- EventToolStart{ID: t.toolID, Name: t.toolName} | |
| 444 | + | } | |
| 445 | + | if tc.Function.Arguments != "" { | |
| 446 | + | t.toolArgs.WriteString(tc.Function.Arguments) | |
| 447 | + | if t.announced { | |
| 448 | + | out <- EventToolArgs{ID: t.toolID, Delta: tc.Function.Arguments} | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | switch ch.FinishReason { | |
| 453 | + | case "stop": | |
| 454 | + | stop = StopEnd | |
| 455 | + | case "length": | |
| 456 | + | stop = StopLength | |
| 457 | + | case "tool_calls", "function_call": | |
| 458 | + | stop = StopToolUse | |
| 459 | + | for _, b := range blocks { | |
| 460 | + | if b.kind == "tool_use" && b.announced { | |
| 461 | + | out <- EventToolEnd{ID: b.toolID} | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | } | |
A internal/provider/openai_codex.go +484 -0
| @@ -0,0 +1,484 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "runtime" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Codex (ChatGPT subscription) client. Uses OpenAI's Responses API via | |
| 17 | + | // chatgpt.com/backend-api with the chatgpt-account-id handshake. | |
| 18 | + | // | |
| 19 | + | // Wire protocol notes: | |
| 20 | + | // - Endpoint: POST https://chatgpt.com/backend-api/codex/responses | |
| 21 | + | // - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>, | |
| 22 | + | // OpenAI-Beta: responses=experimental, originator: zot | |
| 23 | + | // - Body: OpenAI Responses API shape (not chat/completions). | |
| 24 | + | // input: [{role, content: [{type: "input_text" | "input_image" | ... }]}] | |
| 25 | + | // instructions: <system prompt> | |
| 26 | + | // tools: [{type:"function", name, description, parameters, strict}] | |
| 27 | + | // stream: true | |
| 28 | + | // - SSE events: response.output_item.added, response.output_text.delta, | |
| 29 | + | // response.function_call_arguments.delta, response.output_item.done, | |
| 30 | + | // response.completed, response.failed, error | |
| 31 | + | // | |
| 32 | + | // The access_token comes from the OpenAI OAuth flow (auth.openai.com). | |
| 33 | + | // The accountID is parsed from the id_token JWT's `chatgpt_account_id` | |
| 34 | + | // claim; see auth/oauth.go. | |
| 35 | + | ||
| 36 | + | const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses" | |
| 37 | + | ||
| 38 | + | type codexClient struct { | |
| 39 | + | token string | |
| 40 | + | accountID string | |
| 41 | + | baseURL string | |
| 42 | + | http *http.Client | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | |
| 46 | + | // using a subscription OAuth access token and the user's ChatGPT | |
| 47 | + | // account id. baseURL may be empty to use the default. | |
| 48 | + | func NewOpenAICodex(token, accountID, baseURL string) Client { | |
| 49 | + | if baseURL == "" { | |
| 50 | + | baseURL = codexDefaultBaseURL | |
| 51 | + | } | |
| 52 | + | return &codexClient{ | |
| 53 | + | token: token, | |
| 54 | + | accountID: accountID, | |
| 55 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 56 | + | http: &http.Client{Timeout: 0}, | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (c *codexClient) Name() string { return "openai" } | |
| 61 | + | ||
| 62 | + | // ---- Responses API wire types (subset needed for zot's surface) ---- | |
| 63 | + | ||
| 64 | + | type codexInputText struct { | |
| 65 | + | Type string `json:"type"` // "input_text" | |
| 66 | + | Text string `json:"text"` | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type codexInputImage struct { | |
| 70 | + | Type string `json:"type"` // "input_image" | |
| 71 | + | Detail string `json:"detail"` | |
| 72 | + | ImageURL string `json:"image_url"` | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | type codexOutputText struct { | |
| 76 | + | Type string `json:"type"` // "output_text" | |
| 77 | + | Text string `json:"text"` | |
| 78 | + | Annotations []any `json:"annotations"` | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | type codexInputMessage struct { | |
| 82 | + | Type string `json:"type,omitempty"` // "message" (optional for input) | |
| 83 | + | Role string `json:"role"` | |
| 84 | + | Content []any `json:"content"` | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | type codexOutputMessage struct { | |
| 88 | + | Type string `json:"type"` // "message" | |
| 89 | + | Role string `json:"role"` | |
| 90 | + | Status string `json:"status,omitempty"` | |
| 91 | + | ID string `json:"id,omitempty"` | |
| 92 | + | Content []codexOutputText `json:"content"` | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | type codexFunctionCall struct { | |
| 96 | + | Type string `json:"type"` // "function_call" | |
| 97 | + | ID string `json:"id,omitempty"` | |
| 98 | + | CallID string `json:"call_id"` | |
| 99 | + | Name string `json:"name"` | |
| 100 | + | Arguments string `json:"arguments"` // JSON string | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | type codexFunctionCallOutput struct { | |
| 104 | + | Type string `json:"type"` // "function_call_output" | |
| 105 | + | CallID string `json:"call_id"` | |
| 106 | + | Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | type codexTool struct { | |
| 110 | + | Type string `json:"type"` // "function" | |
| 111 | + | Name string `json:"name"` | |
| 112 | + | Description string `json:"description,omitempty"` | |
| 113 | + | Parameters json.RawMessage `json:"parameters"` | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | type codexRequest struct { | |
| 117 | + | Model string `json:"model"` | |
| 118 | + | Store bool `json:"store"` | |
| 119 | + | Stream bool `json:"stream"` | |
| 120 | + | Instructions string `json:"instructions,omitempty"` | |
| 121 | + | Input []any `json:"input"` | |
| 122 | + | Tools []codexTool `json:"tools,omitempty"` | |
| 123 | + | ToolChoice string `json:"tool_choice,omitempty"` | |
| 124 | + | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 125 | + | Include []string `json:"include,omitempty"` | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ---- Request building ---- | |
| 129 | + | ||
| 130 | + | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 131 | + | m, err := FindModel("openai", req.Model) | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, err | |
| 134 | + | } | |
| 135 | + | _ = m | |
| 136 | + | ||
| 137 | + | body := &codexRequest{ | |
| 138 | + | Model: req.Model, | |
| 139 | + | Store: false, | |
| 140 | + | Stream: true, | |
| 141 | + | Instructions: req.System, | |
| 142 | + | ParallelToolCalls: true, | |
| 143 | + | Include: []string{"reasoning.encrypted_content"}, | |
| 144 | + | } | |
| 145 | + | if len(req.Tools) > 0 { | |
| 146 | + | body.ToolChoice = "auto" | |
| 147 | + | for _, t := range req.Tools { | |
| 148 | + | params := t.Schema | |
| 149 | + | if len(params) == 0 { | |
| 150 | + | params = json.RawMessage(`{"type":"object","properties":{}}`) | |
| 151 | + | } | |
| 152 | + | body.Tools = append(body.Tools, codexTool{ | |
| 153 | + | Type: "function", | |
| 154 | + | Name: t.Name, | |
| 155 | + | Description: t.Description, | |
| 156 | + | Parameters: params, | |
| 157 | + | }) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | msgIdx := 0 | |
| 162 | + | for _, msg := range req.Messages { | |
| 163 | + | switch msg.Role { | |
| 164 | + | case RoleUser: | |
| 165 | + | content := []any{} | |
| 166 | + | for _, c := range msg.Content { | |
| 167 | + | switch v := c.(type) { | |
| 168 | + | case TextBlock: | |
| 169 | + | if v.Text != "" { | |
| 170 | + | content = append(content, codexInputText{Type: "input_text", Text: v.Text}) | |
| 171 | + | } | |
| 172 | + | case ImageBlock: | |
| 173 | + | url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data) | |
| 174 | + | content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url}) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if len(content) == 0 { | |
| 178 | + | continue | |
| 179 | + | } | |
| 180 | + | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | |
| 181 | + | case RoleAssistant: | |
| 182 | + | // Emit one output_message per text block and one function_call per tool call, | |
| 183 | + | // preserving the order so model sees the same interleaving we captured. | |
| 184 | + | for _, c := range msg.Content { | |
| 185 | + | switch v := c.(type) { | |
| 186 | + | case TextBlock: | |
| 187 | + | if v.Text == "" { | |
| 188 | + | continue | |
| 189 | + | } | |
| 190 | + | msgIdx++ | |
| 191 | + | body.Input = append(body.Input, codexOutputMessage{ | |
| 192 | + | Type: "message", | |
| 193 | + | Role: "assistant", | |
| 194 | + | Status: "completed", | |
| 195 | + | ID: fmt.Sprintf("msg_%d", msgIdx), | |
| 196 | + | Content: []codexOutputText{ | |
| 197 | + | {Type: "output_text", Text: v.Text, Annotations: []any{}}, | |
| 198 | + | }, | |
| 199 | + | }) | |
| 200 | + | case ToolCallBlock: | |
| 201 | + | args := string(v.Arguments) | |
| 202 | + | if args == "" { | |
| 203 | + | args = "{}" | |
| 204 | + | } | |
| 205 | + | callID, _ := splitCallID(v.ID) | |
| 206 | + | body.Input = append(body.Input, codexFunctionCall{ | |
| 207 | + | Type: "function_call", | |
| 208 | + | CallID: callID, | |
| 209 | + | Name: v.Name, | |
| 210 | + | Arguments: args, | |
| 211 | + | }) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | case RoleTool: | |
| 215 | + | for _, c := range msg.Content { | |
| 216 | + | if tr, ok := c.(ToolResultBlock); ok { | |
| 217 | + | var text strings.Builder | |
| 218 | + | for _, inner := range tr.Content { | |
| 219 | + | if tb, ok := inner.(TextBlock); ok { | |
| 220 | + | if text.Len() > 0 { | |
| 221 | + | text.WriteString("\n") | |
| 222 | + | } | |
| 223 | + | text.WriteString(tb.Text) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | callID, _ := splitCallID(tr.CallID) | |
| 227 | + | body.Input = append(body.Input, codexFunctionCallOutput{ | |
| 228 | + | Type: "function_call_output", | |
| 229 | + | CallID: callID, | |
| 230 | + | Output: text.String(), | |
| 231 | + | }) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | return body, nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func splitCallID(id string) (string, string) { | |
| 241 | + | if i := strings.Index(id, "|"); i >= 0 { | |
| 242 | + | return id[:i], id[i+1:] | |
| 243 | + | } | |
| 244 | + | return id, "" | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // ---- Streaming ---- | |
| 248 | + | ||
| 249 | + | func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 250 | + | wire, err := c.buildRequest(req) | |
| 251 | + | if err != nil { | |
| 252 | + | return nil, err | |
| 253 | + | } | |
| 254 | + | body, err := json.Marshal(wire) | |
| 255 | + | if err != nil { | |
| 256 | + | return nil, err | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body)) | |
| 260 | + | if err != nil { | |
| 261 | + | return nil, err | |
| 262 | + | } | |
| 263 | + | httpReq.Header.Set("content-type", "application/json") | |
| 264 | + | httpReq.Header.Set("accept", "text/event-stream") | |
| 265 | + | httpReq.Header.Set("authorization", "Bearer "+c.token) | |
| 266 | + | httpReq.Header.Set("chatgpt-account-id", c.accountID) | |
| 267 | + | httpReq.Header.Set("openai-beta", "responses=experimental") | |
| 268 | + | httpReq.Header.Set("originator", "zot") | |
| 269 | + | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 270 | + | ||
| 271 | + | resp, err := c.http.Do(httpReq) | |
| 272 | + | if err != nil { | |
| 273 | + | return nil, fmt.Errorf("openai-codex: %w", err) | |
| 274 | + | } | |
| 275 | + | if resp.StatusCode != http.StatusOK { | |
| 276 | + | b, _ := io.ReadAll(resp.Body) | |
| 277 | + | resp.Body.Close() | |
| 278 | + | return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | out := make(chan Event, 16) | |
| 282 | + | go c.runStream(ctx, resp, req, out) | |
| 283 | + | return out, nil | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { | |
| 287 | + | defer close(out) | |
| 288 | + | defer resp.Body.Close() | |
| 289 | + | ||
| 290 | + | model, _ := FindModel("openai", req.Model) | |
| 291 | + | out <- EventStart{Model: req.Model, Provider: "openai"} | |
| 292 | + | ||
| 293 | + | raw := make(chan sseEvent, 16) | |
| 294 | + | go readSSE(resp.Body, raw) | |
| 295 | + | ||
| 296 | + | // Accumulators. The Responses API emits output_items in order; each | |
| 297 | + | // item is either a "message" (text) or a "function_call". We track | |
| 298 | + | // the in-flight item by its index. | |
| 299 | + | type itemState struct { | |
| 300 | + | kind string // "message" | "function_call" | |
| 301 | + | callID string | |
| 302 | + | name string | |
| 303 | + | argsBuf strings.Builder | |
| 304 | + | textBuf strings.Builder | |
| 305 | + | announced bool | |
| 306 | + | } | |
| 307 | + | var ( | |
| 308 | + | items = map[int]*itemState{} | |
| 309 | + | order []int | |
| 310 | + | usage Usage | |
| 311 | + | stop StopReason = StopEnd | |
| 312 | + | finalErr error | |
| 313 | + | ) | |
| 314 | + | ||
| 315 | + | assemble := func() Message { | |
| 316 | + | content := []Content{} | |
| 317 | + | for _, idx := range order { | |
| 318 | + | it := items[idx] | |
| 319 | + | switch it.kind { | |
| 320 | + | case "message": | |
| 321 | + | if it.textBuf.Len() > 0 { | |
| 322 | + | content = append(content, TextBlock{Text: it.textBuf.String()}) | |
| 323 | + | } | |
| 324 | + | case "function_call": | |
| 325 | + | args := it.argsBuf.String() | |
| 326 | + | if args == "" { | |
| 327 | + | args = "{}" | |
| 328 | + | } | |
| 329 | + | content = append(content, ToolCallBlock{ | |
| 330 | + | ID: it.callID, Name: it.name, Arguments: json.RawMessage(args), | |
| 331 | + | }) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | return Message{Role: RoleAssistant, Content: content, Time: time.Now()} | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | sendDone := func() { | |
| 338 | + | usage.CostUSD = ComputeCost(model, usage) | |
| 339 | + | out <- EventUsage{Usage: usage} | |
| 340 | + | out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()} | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | for { | |
| 344 | + | select { | |
| 345 | + | case <-ctx.Done(): | |
| 346 | + | stop = StopAborted | |
| 347 | + | finalErr = ctx.Err() | |
| 348 | + | sendDone() | |
| 349 | + | return | |
| 350 | + | case ev, ok := <-raw: | |
| 351 | + | if !ok { | |
| 352 | + | sendDone() | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | var head struct { | |
| 356 | + | Type string `json:"type"` | |
| 357 | + | } | |
| 358 | + | if err := json.Unmarshal([]byte(ev.Data), &head); err != nil { | |
| 359 | + | continue | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | switch head.Type { | |
| 363 | + | case "response.output_item.added": | |
| 364 | + | var p struct { | |
| 365 | + | OutputIndex int `json:"output_index"` | |
| 366 | + | Item struct { | |
| 367 | + | Type string `json:"type"` // "message" | "function_call" | |
| 368 | + | ID string `json:"id"` | |
| 369 | + | CallID string `json:"call_id"` | |
| 370 | + | Name string `json:"name"` | |
| 371 | + | } `json:"item"` | |
| 372 | + | } | |
| 373 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 374 | + | it := &itemState{} | |
| 375 | + | switch p.Item.Type { | |
| 376 | + | case "message": | |
| 377 | + | it.kind = "message" | |
| 378 | + | case "function_call": | |
| 379 | + | it.kind = "function_call" | |
| 380 | + | it.callID = p.Item.CallID | |
| 381 | + | it.name = p.Item.Name | |
| 382 | + | if !it.announced { | |
| 383 | + | it.announced = true | |
| 384 | + | out <- EventToolStart{ID: it.callID, Name: it.name} | |
| 385 | + | } | |
| 386 | + | default: | |
| 387 | + | continue | |
| 388 | + | } | |
| 389 | + | items[p.OutputIndex] = it | |
| 390 | + | order = append(order, p.OutputIndex) | |
| 391 | + | case "response.output_text.delta": | |
| 392 | + | var p struct { | |
| 393 | + | OutputIndex int `json:"output_index"` | |
| 394 | + | Delta string `json:"delta"` | |
| 395 | + | } | |
| 396 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 397 | + | if it, ok := items[p.OutputIndex]; ok && it.kind == "message" { | |
| 398 | + | it.textBuf.WriteString(p.Delta) | |
| 399 | + | out <- EventTextDelta{Delta: p.Delta} | |
| 400 | + | } | |
| 401 | + | case "response.function_call_arguments.delta": | |
| 402 | + | var p struct { | |
| 403 | + | OutputIndex int `json:"output_index"` | |
| 404 | + | Delta string `json:"delta"` | |
| 405 | + | } | |
| 406 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 407 | + | if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" { | |
| 408 | + | it.argsBuf.WriteString(p.Delta) | |
| 409 | + | out <- EventToolArgs{ID: it.callID, Delta: p.Delta} | |
| 410 | + | } | |
| 411 | + | case "response.output_item.done": | |
| 412 | + | var p struct { | |
| 413 | + | OutputIndex int `json:"output_index"` | |
| 414 | + | } | |
| 415 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 416 | + | if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" { | |
| 417 | + | out <- EventToolEnd{ID: it.callID} | |
| 418 | + | } | |
| 419 | + | case "response.completed", "response.done": | |
| 420 | + | var p struct { | |
| 421 | + | Response struct { | |
| 422 | + | Usage struct { | |
| 423 | + | InputTokens int `json:"input_tokens"` | |
| 424 | + | OutputTokens int `json:"output_tokens"` | |
| 425 | + | InputTokensDetails struct { | |
| 426 | + | CachedTokens int `json:"cached_tokens"` | |
| 427 | + | } `json:"input_tokens_details"` | |
| 428 | + | } `json:"usage"` | |
| 429 | + | Status string `json:"status"` | |
| 430 | + | } `json:"response"` | |
| 431 | + | } | |
| 432 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 433 | + | usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens | |
| 434 | + | if usage.InputTokens < 0 { | |
| 435 | + | usage.InputTokens = p.Response.Usage.InputTokens | |
| 436 | + | } | |
| 437 | + | usage.OutputTokens = p.Response.Usage.OutputTokens | |
| 438 | + | usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens | |
| 439 | + | ||
| 440 | + | hadTool := false | |
| 441 | + | for _, it := range items { | |
| 442 | + | if it.kind == "function_call" { | |
| 443 | + | hadTool = true | |
| 444 | + | break | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | if hadTool { | |
| 448 | + | stop = StopToolUse | |
| 449 | + | } else { | |
| 450 | + | stop = StopEnd | |
| 451 | + | } | |
| 452 | + | sendDone() | |
| 453 | + | return | |
| 454 | + | case "response.failed": | |
| 455 | + | var p struct { | |
| 456 | + | Response struct { | |
| 457 | + | Error struct { | |
| 458 | + | Message string `json:"message"` | |
| 459 | + | } `json:"error"` | |
| 460 | + | } `json:"response"` | |
| 461 | + | } | |
| 462 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 463 | + | stop = StopError | |
| 464 | + | finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message) | |
| 465 | + | sendDone() | |
| 466 | + | return | |
| 467 | + | case "error": | |
| 468 | + | var p struct { | |
| 469 | + | Message string `json:"message"` | |
| 470 | + | Code string `json:"code"` | |
| 471 | + | } | |
| 472 | + | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 473 | + | msg := p.Message | |
| 474 | + | if msg == "" { | |
| 475 | + | msg = p.Code | |
| 476 | + | } | |
| 477 | + | stop = StopError | |
| 478 | + | finalErr = fmt.Errorf("codex error: %s", msg) | |
| 479 | + | sendDone() | |
| 480 | + | return | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | } | |
A internal/provider/provider.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | // Package provider defines the LLM client abstraction used by zot. | |
| 2 | + | // | |
| 3 | + | // It supports exactly two providers: Anthropic (Messages API) and | |
| 4 | + | // OpenAI (Chat Completions API). Everything above this package operates | |
| 5 | + | // on the types declared here and does not know about HTTP or SSE. | |
| 6 | + | package provider | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "encoding/json" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Role is the speaker of a Message. | |
| 15 | + | type Role string | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | RoleUser Role = "user" | |
| 19 | + | RoleAssistant Role = "assistant" | |
| 20 | + | RoleTool Role = "tool" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // Content is a block inside a Message. One of TextBlock, ImageBlock, | |
| 24 | + | // ToolCallBlock, or ToolResultBlock. | |
| 25 | + | type Content interface { | |
| 26 | + | isContent() | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // TextBlock is plain text content. | |
| 30 | + | type TextBlock struct { | |
| 31 | + | Text string `json:"text"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (TextBlock) isContent() {} | |
| 35 | + | ||
| 36 | + | // ImageBlock is an inline image (PNG/JPEG/GIF/WebP). | |
| 37 | + | type ImageBlock struct { | |
| 38 | + | MimeType string `json:"mime_type"` | |
| 39 | + | Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (ImageBlock) isContent() {} | |
| 43 | + | ||
| 44 | + | // ToolCallBlock is an assistant-issued call to a tool. | |
| 45 | + | type ToolCallBlock struct { | |
| 46 | + | ID string `json:"id"` | |
| 47 | + | Name string `json:"name"` | |
| 48 | + | Arguments json.RawMessage `json:"arguments"` | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (ToolCallBlock) isContent() {} | |
| 52 | + | ||
| 53 | + | // ToolResultBlock is the result of a tool execution, attached to a | |
| 54 | + | // Message with Role == RoleTool. | |
| 55 | + | type ToolResultBlock struct { | |
| 56 | + | CallID string `json:"call_id"` | |
| 57 | + | Content []Content `json:"content"` | |
| 58 | + | IsError bool `json:"is_error"` | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (ToolResultBlock) isContent() {} | |
| 62 | + | ||
| 63 | + | // Message is a single turn in the conversation. | |
| 64 | + | type Message struct { | |
| 65 | + | Role Role `json:"role"` | |
| 66 | + | Content []Content `json:"content"` | |
| 67 | + | Time time.Time `json:"time"` | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Tool is a tool definition advertised to the LLM. | |
| 71 | + | type Tool struct { | |
| 72 | + | Name string `json:"name"` | |
| 73 | + | Description string `json:"description"` | |
| 74 | + | Schema json.RawMessage `json:"schema"` // JSON Schema for arguments | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Usage aggregates token counts and cost for a turn. | |
| 78 | + | type Usage struct { | |
| 79 | + | InputTokens int `json:"input_tokens"` | |
| 80 | + | OutputTokens int `json:"output_tokens"` | |
| 81 | + | CacheReadTokens int `json:"cache_read_tokens"` | |
| 82 | + | CacheWriteTokens int `json:"cache_write_tokens"` | |
| 83 | + | CostUSD float64 `json:"cost_usd"` | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Add returns u plus v. | |
| 87 | + | func (u Usage) Add(v Usage) Usage { | |
| 88 | + | return Usage{ | |
| 89 | + | InputTokens: u.InputTokens + v.InputTokens, | |
| 90 | + | OutputTokens: u.OutputTokens + v.OutputTokens, | |
| 91 | + | CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens, | |
| 92 | + | CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens, | |
| 93 | + | CostUSD: u.CostUSD + v.CostUSD, | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // StopReason describes why a turn ended. | |
| 98 | + | type StopReason string | |
| 99 | + | ||
| 100 | + | const ( | |
| 101 | + | StopEnd StopReason = "end" | |
| 102 | + | StopLength StopReason = "length" | |
| 103 | + | StopToolUse StopReason = "tool_use" | |
| 104 | + | StopError StopReason = "error" | |
| 105 | + | StopAborted StopReason = "aborted" | |
| 106 | + | ) | |
| 107 | + | ||
| 108 | + | // Event is one item from a provider stream. | |
| 109 | + | type Event interface { | |
| 110 | + | isEvent() | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | type EventStart struct { | |
| 114 | + | Model string | |
| 115 | + | Provider string | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (EventStart) isEvent() {} | |
| 119 | + | ||
| 120 | + | type EventTextDelta struct { | |
| 121 | + | Delta string | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (EventTextDelta) isEvent() {} | |
| 125 | + | ||
| 126 | + | type EventToolStart struct { | |
| 127 | + | ID string | |
| 128 | + | Name string | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (EventToolStart) isEvent() {} | |
| 132 | + | ||
| 133 | + | type EventToolArgs struct { | |
| 134 | + | ID string | |
| 135 | + | Delta string // partial JSON | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (EventToolArgs) isEvent() {} | |
| 139 | + | ||
| 140 | + | type EventToolEnd struct { | |
| 141 | + | ID string | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (EventToolEnd) isEvent() {} | |
| 145 | + | ||
| 146 | + | type EventUsage struct { | |
| 147 | + | Usage Usage | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (EventUsage) isEvent() {} | |
| 151 | + | ||
| 152 | + | // EventDone is always the final event on a stream. | |
| 153 | + | type EventDone struct { | |
| 154 | + | Stop StopReason | |
| 155 | + | Err error | |
| 156 | + | Message Message // fully assembled assistant message | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (EventDone) isEvent() {} | |
| 160 | + | ||
| 161 | + | // Request is a single LLM call. | |
| 162 | + | type Request struct { | |
| 163 | + | Model string | |
| 164 | + | System string | |
| 165 | + | Messages []Message | |
| 166 | + | Tools []Tool | |
| 167 | + | MaxTokens int | |
| 168 | + | Temperature *float32 | |
| 169 | + | // Reasoning is "", "low", "medium", or "high". | |
| 170 | + | // Anthropic maps it to extended-thinking budget tokens. | |
| 171 | + | // OpenAI maps it to reasoning_effort. | |
| 172 | + | Reasoning string | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Client is an LLM streaming client. | |
| 176 | + | type Client interface { | |
| 177 | + | // Name returns "anthropic" or "openai". | |
| 178 | + | Name() string | |
| 179 | + | // Stream starts a request. The returned channel delivers events | |
| 180 | + | // and is closed after EventDone. Errors during request setup are | |
| 181 | + | // returned directly; runtime errors arrive as EventDone{Err: ...}. | |
| 182 | + | Stream(ctx context.Context, req Request) (<-chan Event, error) | |
| 183 | + | } | |
A internal/provider/provider_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSSEParse(t *testing.T) { | |
| 12 | + | r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n") | |
| 13 | + | ch := make(chan sseEvent, 4) | |
| 14 | + | go readSSE(r, ch) | |
| 15 | + | ||
| 16 | + | e := <-ch | |
| 17 | + | if e.Event != "foo" || e.Data != `{"a":1}` { | |
| 18 | + | t.Fatalf("event 1: %+v", e) | |
| 19 | + | } | |
| 20 | + | e = <-ch | |
| 21 | + | if e.Event != "" || e.Data != "hello\nworld" { | |
| 22 | + | t.Fatalf("event 2: %+v", e) | |
| 23 | + | } | |
| 24 | + | if _, ok := <-ch; ok { | |
| 25 | + | t.Fatalf("channel not closed") | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestModelCatalog(t *testing.T) { | |
| 30 | + | if len(Catalog) == 0 { | |
| 31 | + | t.Fatal("empty catalog") | |
| 32 | + | } | |
| 33 | + | if _, err := FindModel("anthropic", "claude-sonnet-4-5"); err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if _, err := FindModel("openai", "gpt-5"); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if _, err := FindModel("", "nope"); err == nil { | |
| 40 | + | t.Fatal("expected error") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestComputeCost(t *testing.T) { | |
| 45 | + | m, _ := FindModel("anthropic", "claude-sonnet-4-5") | |
| 46 | + | cost := ComputeCost(m, Usage{InputTokens: 1_000_000, OutputTokens: 1_000_000}) | |
| 47 | + | want := m.PriceInput + m.PriceOutput | |
| 48 | + | if cost != want { | |
| 49 | + | t.Fatalf("cost=%v want=%v", cost, want) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestAnthropicErrorStatus(t *testing.T) { | |
| 54 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 55 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 56 | + | _, _ = w.Write([]byte(`{"error":{"type":"auth","message":"bad key"}}`)) | |
| 57 | + | })) | |
| 58 | + | defer srv.Close() | |
| 59 | + | ||
| 60 | + | c := NewAnthropic("x", srv.URL) | |
| 61 | + | _, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"}) | |
| 62 | + | if err == nil || !strings.Contains(err.Error(), "401") { | |
| 63 | + | t.Fatalf("want 401 err, got %v", err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestOpenAIErrorStatus(t *testing.T) { | |
| 68 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 69 | + | w.WriteHeader(http.StatusBadRequest) | |
| 70 | + | _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`)) | |
| 71 | + | })) | |
| 72 | + | defer srv.Close() | |
| 73 | + | ||
| 74 | + | c := NewOpenAI("x", srv.URL) | |
| 75 | + | _, err := c.Stream(context.Background(), Request{Model: "gpt-5"}) | |
| 76 | + | if err == nil || !strings.Contains(err.Error(), "400") { | |
| 77 | + | t.Fatalf("want 400 err, got %v", err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestAnthropicStreamHappyPath(t *testing.T) { | |
| 82 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 83 | + | w.Header().Set("content-type", "text/event-stream") | |
| 84 | + | w.WriteHeader(http.StatusOK) | |
| 85 | + | fl, _ := w.(http.Flusher) | |
| 86 | + | write := func(s string) { | |
| 87 | + | _, _ = w.Write([]byte(s)) | |
| 88 | + | if fl != nil { | |
| 89 | + | fl.Flush() | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | write("event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n") | |
| 93 | + | write("event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n") | |
| 94 | + | write("event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n") | |
| 95 | + | write("event: content_block_stop\ndata: {\"index\":0}\n\n") | |
| 96 | + | write("event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n") | |
| 97 | + | write("event: message_stop\ndata: {}\n\n") | |
| 98 | + | })) | |
| 99 | + | defer srv.Close() | |
| 100 | + | ||
| 101 | + | c := NewAnthropic("x", srv.URL) | |
| 102 | + | evs, err := c.Stream(context.Background(), Request{Model: "claude-sonnet-4-5"}) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | var gotText string | |
| 107 | + | var done EventDone | |
| 108 | + | for ev := range evs { | |
| 109 | + | switch e := ev.(type) { | |
| 110 | + | case EventTextDelta: | |
| 111 | + | gotText += e.Delta | |
| 112 | + | case EventDone: | |
| 113 | + | done = e | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | if gotText != "hi" { | |
| 117 | + | t.Fatalf("text=%q", gotText) | |
| 118 | + | } | |
| 119 | + | if done.Stop != StopEnd { | |
| 120 | + | t.Fatalf("stop=%v", done.Stop) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestOpenAIStreamHappyPath(t *testing.T) { | |
| 125 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 126 | + | w.Header().Set("content-type", "text/event-stream") | |
| 127 | + | w.WriteHeader(http.StatusOK) | |
| 128 | + | fl, _ := w.(http.Flusher) | |
| 129 | + | write := func(s string) { | |
| 130 | + | _, _ = w.Write([]byte(s)) | |
| 131 | + | if fl != nil { | |
| 132 | + | fl.Flush() | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n") | |
| 136 | + | write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n") | |
| 137 | + | write("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":2}}\n\n") | |
| 138 | + | write("data: [DONE]\n\n") | |
| 139 | + | })) | |
| 140 | + | defer srv.Close() | |
| 141 | + | ||
| 142 | + | c := NewOpenAI("x", srv.URL) | |
| 143 | + | evs, err := c.Stream(context.Background(), Request{Model: "gpt-5"}) | |
| 144 | + | if err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | var gotText string | |
| 148 | + | var done EventDone | |
| 149 | + | for ev := range evs { | |
| 150 | + | switch e := ev.(type) { | |
| 151 | + | case EventTextDelta: | |
| 152 | + | gotText += e.Delta | |
| 153 | + | case EventDone: | |
| 154 | + | done = e | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if gotText != "hello" { | |
| 158 | + | t.Fatalf("text=%q", gotText) | |
| 159 | + | } | |
| 160 | + | if done.Stop != StopEnd { | |
| 161 | + | t.Fatalf("stop=%v", done.Stop) | |
| 162 | + | } | |
| 163 | + | } | |
A internal/provider/sse.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // sseEvent is one parsed event from a text/event-stream. | |
| 10 | + | type sseEvent struct { | |
| 11 | + | Event string // value of "event:" field (may be empty) | |
| 12 | + | Data string // concatenated "data:" lines | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // readSSE reads events from r and sends them on out. It closes out when r | |
| 16 | + | // is exhausted or a read error occurs. | |
| 17 | + | func readSSE(r io.Reader, out chan<- sseEvent) { | |
| 18 | + | defer close(out) | |
| 19 | + | sc := bufio.NewScanner(r) | |
| 20 | + | sc.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) | |
| 21 | + | ||
| 22 | + | var ev sseEvent | |
| 23 | + | flush := func() { | |
| 24 | + | if ev.Data == "" && ev.Event == "" { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | out <- ev | |
| 28 | + | ev = sseEvent{} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | for sc.Scan() { | |
| 32 | + | line := sc.Text() | |
| 33 | + | if line == "" { | |
| 34 | + | flush() | |
| 35 | + | continue | |
| 36 | + | } | |
| 37 | + | if strings.HasPrefix(line, ":") { | |
| 38 | + | // comment / keep-alive | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | field, value, ok := strings.Cut(line, ":") | |
| 42 | + | if !ok { | |
| 43 | + | field = line | |
| 44 | + | value = "" | |
| 45 | + | } | |
| 46 | + | // optional single leading space after ':' | |
| 47 | + | value = strings.TrimPrefix(value, " ") | |
| 48 | + | switch field { | |
| 49 | + | case "event": | |
| 50 | + | ev.Event = value | |
| 51 | + | case "data": | |
| 52 | + | if ev.Data != "" { | |
| 53 | + | ev.Data += "\n" | |
| 54 | + | } | |
| 55 | + | ev.Data += value | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | flush() | |
| 59 | + | } | |
A internal/tui/editor.go +636 -0
| @@ -0,0 +1,636 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/mattn/go-runewidth" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Editor is a simple multi-line text editor for the input area. | |
| 10 | + | // | |
| 11 | + | // Users can type, paste, move the cursor, and submit. The editor | |
| 12 | + | // exposes its rendered height and the current cursor row/col for the | |
| 13 | + | // outer renderer. It does NOT draw itself directly; instead, Render() | |
| 14 | + | // returns the visible lines. | |
| 15 | + | type Editor struct { | |
| 16 | + | // Lines is the current buffer, one entry per line. | |
| 17 | + | Lines []string | |
| 18 | + | CursorR int // row index into Lines | |
| 19 | + | CursorC int // rune index into Lines[CursorR] | |
| 20 | + | Prompt string | |
| 21 | + | MaxWidth int | |
| 22 | + | ||
| 23 | + | // History is a ring of previously submitted buffers (newest last). | |
| 24 | + | History []string | |
| 25 | + | histIdx int // -1 means "editing current buffer" | |
| 26 | + | savedDraft string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewEditor returns an empty editor with the given prompt. | |
| 30 | + | func NewEditor(prompt string) *Editor { | |
| 31 | + | return &Editor{ | |
| 32 | + | Lines: []string{""}, | |
| 33 | + | Prompt: prompt, | |
| 34 | + | histIdx: -1, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Value returns the buffer as a single string. | |
| 39 | + | func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") } | |
| 40 | + | ||
| 41 | + | // SetValue replaces the buffer and places the cursor at the end. | |
| 42 | + | func (e *Editor) SetValue(s string) { | |
| 43 | + | e.Lines = strings.Split(s, "\n") | |
| 44 | + | if len(e.Lines) == 0 { | |
| 45 | + | e.Lines = []string{""} | |
| 46 | + | } | |
| 47 | + | e.CursorR = len(e.Lines) - 1 | |
| 48 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 49 | + | e.histIdx = -1 | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Clear resets the buffer. | |
| 53 | + | func (e *Editor) Clear() { e.SetValue("") } | |
| 54 | + | ||
| 55 | + | // IsEmpty reports whether the buffer has no visible content. | |
| 56 | + | func (e *Editor) IsEmpty() bool { | |
| 57 | + | return len(e.Lines) == 1 && e.Lines[0] == "" | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // HandleKey applies k to the editor. It returns (submit, key). | |
| 61 | + | // If submit is true, the caller should read Value() and call | |
| 62 | + | // PushHistory + Clear. | |
| 63 | + | func (e *Editor) HandleKey(k Key) (submit bool) { | |
| 64 | + | switch k.Kind { | |
| 65 | + | case KeyRune: | |
| 66 | + | if k.Alt && (k.Rune == '\r' || k.Rune == '\n') { | |
| 67 | + | e.newline() | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | e.insert(string(k.Rune)) | |
| 71 | + | case KeyEnter: | |
| 72 | + | // Shift+Enter would be a separate key in some terminals; we treat | |
| 73 | + | // the literal newline byte as Enter. Newline on submit is a decision | |
| 74 | + | // for the outer UI via slash commands. Here Enter submits. | |
| 75 | + | return true | |
| 76 | + | case KeyBackspace: | |
| 77 | + | if k.Alt { | |
| 78 | + | // Alt+Backspace (Option+Delete on macOS) — delete previous word. | |
| 79 | + | e.deleteWord() | |
| 80 | + | } else { | |
| 81 | + | e.backspace() | |
| 82 | + | } | |
| 83 | + | case KeyDelete: | |
| 84 | + | e.delete() | |
| 85 | + | case KeyLeft: | |
| 86 | + | if k.Alt { | |
| 87 | + | e.moveWordLeft() | |
| 88 | + | } else { | |
| 89 | + | e.moveLeft() | |
| 90 | + | } | |
| 91 | + | case KeyRight: | |
| 92 | + | if k.Alt { | |
| 93 | + | e.moveWordRight() | |
| 94 | + | } else { | |
| 95 | + | e.moveRight() | |
| 96 | + | } | |
| 97 | + | case KeyUp: | |
| 98 | + | if e.CursorR == 0 { | |
| 99 | + | e.historyPrev() | |
| 100 | + | } else { | |
| 101 | + | e.CursorR-- | |
| 102 | + | if e.CursorC > runeLen(e.Lines[e.CursorR]) { | |
| 103 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | case KeyDown: | |
| 107 | + | if e.CursorR == len(e.Lines)-1 { | |
| 108 | + | e.historyNext() | |
| 109 | + | } else { | |
| 110 | + | e.CursorR++ | |
| 111 | + | if e.CursorC > runeLen(e.Lines[e.CursorR]) { | |
| 112 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | case KeyHome, KeyCtrlA: | |
| 116 | + | e.CursorC = 0 | |
| 117 | + | case KeyEnd, KeyCtrlE: | |
| 118 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 119 | + | case KeyCtrlU: | |
| 120 | + | e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC) | |
| 121 | + | e.CursorC = 0 | |
| 122 | + | case KeyCtrlK: | |
| 123 | + | e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC) | |
| 124 | + | case KeyCtrlW: | |
| 125 | + | e.deleteWord() | |
| 126 | + | case KeyPaste: | |
| 127 | + | e.insert(k.Paste) | |
| 128 | + | case KeyEsc: | |
| 129 | + | e.Clear() | |
| 130 | + | } | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (e *Editor) insert(s string) { | |
| 135 | + | e.histIdx = -1 | |
| 136 | + | line := e.Lines[e.CursorR] | |
| 137 | + | pre := substringBefore(line, e.CursorC) | |
| 138 | + | post := substringAfter(line, e.CursorC) | |
| 139 | + | // Split pasted text on newlines. | |
| 140 | + | parts := strings.Split(s, "\n") | |
| 141 | + | if len(parts) == 1 { | |
| 142 | + | e.Lines[e.CursorR] = pre + s + post | |
| 143 | + | e.CursorC += runeLen(s) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | newLines := make([]string, 0, len(e.Lines)+len(parts)-1) | |
| 147 | + | newLines = append(newLines, e.Lines[:e.CursorR]...) | |
| 148 | + | newLines = append(newLines, pre+parts[0]) | |
| 149 | + | for i := 1; i < len(parts)-1; i++ { | |
| 150 | + | newLines = append(newLines, parts[i]) | |
| 151 | + | } | |
| 152 | + | last := parts[len(parts)-1] | |
| 153 | + | newLines = append(newLines, last+post) | |
| 154 | + | newLines = append(newLines, e.Lines[e.CursorR+1:]...) | |
| 155 | + | e.Lines = newLines | |
| 156 | + | e.CursorR += len(parts) - 1 | |
| 157 | + | e.CursorC = runeLen(last) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (e *Editor) newline() { | |
| 161 | + | e.histIdx = -1 | |
| 162 | + | line := e.Lines[e.CursorR] | |
| 163 | + | pre := substringBefore(line, e.CursorC) | |
| 164 | + | post := substringAfter(line, e.CursorC) | |
| 165 | + | e.Lines[e.CursorR] = pre | |
| 166 | + | e.Lines = append(e.Lines, "") | |
| 167 | + | copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:]) | |
| 168 | + | e.Lines[e.CursorR+1] = post | |
| 169 | + | e.CursorR++ | |
| 170 | + | e.CursorC = 0 | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (e *Editor) backspace() { | |
| 174 | + | e.histIdx = -1 | |
| 175 | + | if e.CursorC == 0 { | |
| 176 | + | if e.CursorR == 0 { | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | prev := e.Lines[e.CursorR-1] | |
| 180 | + | cur := e.Lines[e.CursorR] | |
| 181 | + | e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...) | |
| 182 | + | e.CursorR-- | |
| 183 | + | e.CursorC = runeLen(prev) | |
| 184 | + | e.Lines[e.CursorR] = prev + cur | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | line := e.Lines[e.CursorR] | |
| 188 | + | e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC) | |
| 189 | + | e.CursorC-- | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (e *Editor) delete() { | |
| 193 | + | e.histIdx = -1 | |
| 194 | + | line := e.Lines[e.CursorR] | |
| 195 | + | if e.CursorC == runeLen(line) { | |
| 196 | + | if e.CursorR == len(e.Lines)-1 { | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | next := e.Lines[e.CursorR+1] | |
| 200 | + | e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...) | |
| 201 | + | e.Lines[e.CursorR] = line + next | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // moveWordLeft jumps the cursor to the start of the previous word, | |
| 208 | + | // using the same word-separator rules as deleteWord. If already at the | |
| 209 | + | // start of the line, wraps to the end of the previous line. | |
| 210 | + | func (e *Editor) moveWordLeft() { | |
| 211 | + | if e.CursorC == 0 { | |
| 212 | + | if e.CursorR > 0 { | |
| 213 | + | e.CursorR-- | |
| 214 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 215 | + | } | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | r := []rune(e.Lines[e.CursorR]) | |
| 219 | + | i := e.CursorC | |
| 220 | + | for i > 0 && isWordSep(r[i-1]) { | |
| 221 | + | i-- | |
| 222 | + | } | |
| 223 | + | for i > 0 && !isWordSep(r[i-1]) { | |
| 224 | + | i-- | |
| 225 | + | } | |
| 226 | + | e.CursorC = i | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // moveWordRight jumps the cursor to the start of the next word. If | |
| 230 | + | // already at the end of the line, wraps to the start of the next line. | |
| 231 | + | func (e *Editor) moveWordRight() { | |
| 232 | + | line := e.Lines[e.CursorR] | |
| 233 | + | if e.CursorC >= runeLen(line) { | |
| 234 | + | if e.CursorR < len(e.Lines)-1 { | |
| 235 | + | e.CursorR++ | |
| 236 | + | e.CursorC = 0 | |
| 237 | + | } | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | r := []rune(line) | |
| 241 | + | i := e.CursorC | |
| 242 | + | for i < len(r) && !isWordSep(r[i]) { | |
| 243 | + | i++ | |
| 244 | + | } | |
| 245 | + | for i < len(r) && isWordSep(r[i]) { | |
| 246 | + | i++ | |
| 247 | + | } | |
| 248 | + | e.CursorC = i | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (e *Editor) moveLeft() { | |
| 252 | + | if e.CursorC > 0 { | |
| 253 | + | e.CursorC-- | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | if e.CursorR > 0 { | |
| 257 | + | e.CursorR-- | |
| 258 | + | e.CursorC = runeLen(e.Lines[e.CursorR]) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (e *Editor) moveRight() { | |
| 263 | + | if e.CursorC < runeLen(e.Lines[e.CursorR]) { | |
| 264 | + | e.CursorC++ | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | if e.CursorR < len(e.Lines)-1 { | |
| 268 | + | e.CursorR++ | |
| 269 | + | e.CursorC = 0 | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (e *Editor) deleteWord() { | |
| 274 | + | e.histIdx = -1 | |
| 275 | + | line := e.Lines[e.CursorR] | |
| 276 | + | if e.CursorC == 0 { | |
| 277 | + | e.backspace() | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | r := []rune(line) | |
| 281 | + | i := e.CursorC | |
| 282 | + | // Step 1: walk over any trailing whitespace to the left of the cursor. | |
| 283 | + | for i > 0 && isWordSep(r[i-1]) { | |
| 284 | + | i-- | |
| 285 | + | } | |
| 286 | + | // Step 2: walk over the word itself (non-separator runes). | |
| 287 | + | for i > 0 && !isWordSep(r[i-1]) { | |
| 288 | + | i-- | |
| 289 | + | } | |
| 290 | + | e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:]) | |
| 291 | + | e.CursorC = i | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // isWordSep reports whether r is a word separator. Anything that isn't | |
| 295 | + | // a letter, digit, or underscore counts as a separator so delete-word | |
| 296 | + | // feels natural on paths, code, and prose. | |
| 297 | + | func isWordSep(r rune) bool { | |
| 298 | + | switch { | |
| 299 | + | case r == '_': | |
| 300 | + | return false | |
| 301 | + | case r >= '0' && r <= '9': | |
| 302 | + | return false | |
| 303 | + | case r >= 'a' && r <= 'z': | |
| 304 | + | return false | |
| 305 | + | case r >= 'A' && r <= 'Z': | |
| 306 | + | return false | |
| 307 | + | case r >= 0x80: | |
| 308 | + | // Treat all non-ASCII as word chars so CJK/äöü don't get chopped. | |
| 309 | + | return false | |
| 310 | + | } | |
| 311 | + | return true | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // ---- history ---- | |
| 315 | + | ||
| 316 | + | // PushHistory saves s to the history ring. | |
| 317 | + | func (e *Editor) PushHistory(s string) { | |
| 318 | + | s = strings.TrimRight(s, "\n") | |
| 319 | + | if s == "" { | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | if n := len(e.History); n > 0 && e.History[n-1] == s { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | e.History = append(e.History, s) | |
| 326 | + | if len(e.History) > 200 { | |
| 327 | + | e.History = e.History[len(e.History)-200:] | |
| 328 | + | } | |
| 329 | + | e.histIdx = -1 | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (e *Editor) historyPrev() { | |
| 333 | + | if len(e.History) == 0 { | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | if e.histIdx == -1 { | |
| 337 | + | e.savedDraft = e.Value() | |
| 338 | + | e.histIdx = len(e.History) - 1 | |
| 339 | + | } else if e.histIdx > 0 { | |
| 340 | + | e.histIdx-- | |
| 341 | + | } | |
| 342 | + | e.SetValue(e.History[e.histIdx]) | |
| 343 | + | // SetValue resets histIdx; restore. | |
| 344 | + | e.histIdx = e.findInHistory() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (e *Editor) historyNext() { | |
| 348 | + | if e.histIdx == -1 { | |
| 349 | + | return | |
| 350 | + | } | |
| 351 | + | if e.histIdx == len(e.History)-1 { | |
| 352 | + | e.histIdx = -1 | |
| 353 | + | e.SetValue(e.savedDraft) | |
| 354 | + | e.histIdx = -1 | |
| 355 | + | return | |
| 356 | + | } | |
| 357 | + | e.histIdx++ | |
| 358 | + | e.SetValue(e.History[e.histIdx]) | |
| 359 | + | e.histIdx = e.findInHistory() | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (e *Editor) findInHistory() int { | |
| 363 | + | v := e.Value() | |
| 364 | + | for i, h := range e.History { | |
| 365 | + | if h == v { | |
| 366 | + | return i | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | return -1 | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // ---- rendering ---- | |
| 373 | + | ||
| 374 | + | // Render returns the editor's visible lines (wrapped to width). | |
| 375 | + | // visualRow/visualCol describe where the cursor lands within the returned lines. | |
| 376 | + | func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |
| 377 | + | promptLen := visibleWidth(e.Prompt) | |
| 378 | + | indent := strings.Repeat(" ", promptLen) | |
| 379 | + | ||
| 380 | + | for r, line := range e.Lines { | |
| 381 | + | var prefix string | |
| 382 | + | if r == 0 { | |
| 383 | + | prefix = e.Prompt | |
| 384 | + | } else { | |
| 385 | + | prefix = indent | |
| 386 | + | } | |
| 387 | + | wrapped := wrapLine(prefix+line, width, indent) | |
| 388 | + | if r == e.CursorR { | |
| 389 | + | // Compute where CursorC lands inside the wrapped rows by | |
| 390 | + | // walking the wrapped output character-by-character and | |
| 391 | + | // tracking which wrapped row + column corresponds to the | |
| 392 | + | // cursor's rune index in `line`. This is the only reliable | |
| 393 | + | // answer under word-wrap, where a simple (promptLen+col)/width | |
| 394 | + | // formula overshoots when wrapLine broke on a space. | |
| 395 | + | targetRunes := e.CursorC | |
| 396 | + | row, col := locateCursor(wrapped, prefix, line, targetRunes, indent) | |
| 397 | + | visualRow = len(lines) + row | |
| 398 | + | visualCol = col | |
| 399 | + | } | |
| 400 | + | lines = append(lines, wrapped...) | |
| 401 | + | } | |
| 402 | + | return lines, visualRow, visualCol | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // locateCursor finds the wrapped row + visible column corresponding to | |
| 406 | + | // `targetRunes` rune positions into the logical `line`, given that the | |
| 407 | + | // wrapped output `wrapped` started with `prefix` on its first row and | |
| 408 | + | // uses `cont` as the continuation indent on subsequent rows. | |
| 409 | + | func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) { | |
| 410 | + | prefixW := visibleWidth(prefix) | |
| 411 | + | contW := visibleWidth(cont) | |
| 412 | + | lineRunes := []rune(line) | |
| 413 | + | if targetRunes > len(lineRunes) { | |
| 414 | + | targetRunes = len(lineRunes) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | seenRunes := 0 | |
| 418 | + | for row, w := range wrapped { | |
| 419 | + | // strip leading prefix / continuation indent before counting runes | |
| 420 | + | body := w | |
| 421 | + | var leadW int | |
| 422 | + | if row == 0 { | |
| 423 | + | if strings.HasPrefix(body, prefix) { | |
| 424 | + | body = body[len(prefix):] | |
| 425 | + | } | |
| 426 | + | leadW = prefixW | |
| 427 | + | } else { | |
| 428 | + | if strings.HasPrefix(body, cont) { | |
| 429 | + | body = body[len(cont):] | |
| 430 | + | } | |
| 431 | + | leadW = contW | |
| 432 | + | } | |
| 433 | + | bodyRunes := []rune(body) | |
| 434 | + | // Could this wrapped row contain the cursor? | |
| 435 | + | if targetRunes <= seenRunes+len(bodyRunes) { | |
| 436 | + | // Column inside body. | |
| 437 | + | inner := targetRunes - seenRunes | |
| 438 | + | if inner < 0 { | |
| 439 | + | inner = 0 | |
| 440 | + | } | |
| 441 | + | col := leadW + runewidth.StringWidth(string(bodyRunes[:inner])) | |
| 442 | + | return row, col | |
| 443 | + | } | |
| 444 | + | seenRunes += len(bodyRunes) | |
| 445 | + | // Word-wrap may have dropped whitespace at the boundary; skip the | |
| 446 | + | // corresponding runes in `line` so counts stay aligned. | |
| 447 | + | for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' { | |
| 448 | + | seenRunes++ | |
| 449 | + | if seenRunes >= targetRunes { | |
| 450 | + | // Cursor landed in the skipped whitespace — place it at | |
| 451 | + | // the start of the next wrapped row. | |
| 452 | + | nextRow := row + 1 | |
| 453 | + | if nextRow >= len(wrapped) { | |
| 454 | + | nextRow = row | |
| 455 | + | } | |
| 456 | + | return nextRow, contW | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | // Fallback: end of the last wrapped row. | |
| 461 | + | if len(wrapped) == 0 { | |
| 462 | + | return 0, prefixW | |
| 463 | + | } | |
| 464 | + | last := wrapped[len(wrapped)-1] | |
| 465 | + | return len(wrapped) - 1, visibleWidth(last) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // ---- helpers ---- | |
| 469 | + | ||
| 470 | + | func substringBefore(s string, col int) string { | |
| 471 | + | r := []rune(s) | |
| 472 | + | if col > len(r) { | |
| 473 | + | col = len(r) | |
| 474 | + | } | |
| 475 | + | return string(r[:col]) | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func substringAfter(s string, col int) string { | |
| 479 | + | r := []rune(s) | |
| 480 | + | if col > len(r) { | |
| 481 | + | col = len(r) | |
| 482 | + | } | |
| 483 | + | return string(r[col:]) | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func runeLen(s string) int { return len([]rune(s)) } | |
| 487 | + | ||
| 488 | + | func visualColumn(s string, runeCol int) int { | |
| 489 | + | r := []rune(s) | |
| 490 | + | if runeCol > len(r) { | |
| 491 | + | runeCol = len(r) | |
| 492 | + | } | |
| 493 | + | return runewidth.StringWidth(string(r[:runeCol])) | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func visibleWidth(s string) int { | |
| 497 | + | return runewidth.StringWidth(stripANSI(s)) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func stripANSI(s string) string { | |
| 501 | + | // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final). | |
| 502 | + | var out []rune | |
| 503 | + | i := 0 | |
| 504 | + | runes := []rune(s) | |
| 505 | + | for i < len(runes) { | |
| 506 | + | if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { | |
| 507 | + | i += 2 | |
| 508 | + | for i < len(runes) { | |
| 509 | + | c := runes[i] | |
| 510 | + | i++ | |
| 511 | + | if c >= 0x40 && c <= 0x7e { | |
| 512 | + | break | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | continue | |
| 516 | + | } | |
| 517 | + | out = append(out, runes[i]) | |
| 518 | + | i++ | |
| 519 | + | } | |
| 520 | + | return string(out) | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func wrapLine(s string, width int, cont string) []string { | |
| 524 | + | if width <= 0 { | |
| 525 | + | return []string{s} | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // Tokenize on spaces while preserving their widths. Runs of spaces | |
| 529 | + | // stay attached to the preceding word so trailing whitespace doesn't | |
| 530 | + | // create empty wrapped lines. | |
| 531 | + | type token struct { | |
| 532 | + | text string | |
| 533 | + | width int | |
| 534 | + | space bool // true if this token is whitespace only | |
| 535 | + | } | |
| 536 | + | var tokens []token | |
| 537 | + | { | |
| 538 | + | var buf strings.Builder | |
| 539 | + | var bufW int | |
| 540 | + | inSpace := false | |
| 541 | + | flush := func() { | |
| 542 | + | if buf.Len() == 0 { | |
| 543 | + | return | |
| 544 | + | } | |
| 545 | + | tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace}) | |
| 546 | + | buf.Reset() | |
| 547 | + | bufW = 0 | |
| 548 | + | } | |
| 549 | + | for _, r := range s { | |
| 550 | + | rSpace := r == ' ' || r == '\t' | |
| 551 | + | if buf.Len() == 0 { | |
| 552 | + | inSpace = rSpace | |
| 553 | + | } else if rSpace != inSpace { | |
| 554 | + | flush() | |
| 555 | + | inSpace = rSpace | |
| 556 | + | } | |
| 557 | + | buf.WriteRune(r) | |
| 558 | + | bufW += runewidth.RuneWidth(r) | |
| 559 | + | } | |
| 560 | + | flush() | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | var out []string | |
| 564 | + | var cur strings.Builder | |
| 565 | + | curW := 0 | |
| 566 | + | firstLine := true | |
| 567 | + | contW := visibleWidth(cont) | |
| 568 | + | ||
| 569 | + | newLine := func() { | |
| 570 | + | out = append(out, cur.String()) | |
| 571 | + | cur.Reset() | |
| 572 | + | curW = 0 | |
| 573 | + | if !firstLine { | |
| 574 | + | cur.WriteString(cont) | |
| 575 | + | curW = contW | |
| 576 | + | } | |
| 577 | + | firstLine = false | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | for i := 0; i < len(tokens); i++ { | |
| 581 | + | tk := tokens[i] | |
| 582 | + | ||
| 583 | + | // Drop leading whitespace after a wrap. | |
| 584 | + | if tk.space && curW == contW && !firstLine { | |
| 585 | + | continue | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | if curW+tk.width <= width { | |
| 589 | + | cur.WriteString(tk.text) | |
| 590 | + | curW += tk.width | |
| 591 | + | continue | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | // Token overflows. If current line has content, break first. | |
| 595 | + | if cur.Len() > 0 && !(firstLine && curW == 0) { | |
| 596 | + | // Drop trailing whitespace on the line we're about to break. | |
| 597 | + | trimmed := strings.TrimRight(cur.String(), " \t") | |
| 598 | + | cur.Reset() | |
| 599 | + | cur.WriteString(trimmed) | |
| 600 | + | curW = visibleWidth(trimmed) | |
| 601 | + | newLine() | |
| 602 | + | // Re-process this token on the fresh line. | |
| 603 | + | if tk.space { | |
| 604 | + | continue | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | // Token is longer than the available width on an empty line. | |
| 609 | + | // Split the token itself rune-by-rune. | |
| 610 | + | if tk.width > width-curW { | |
| 611 | + | for _, r := range tk.text { | |
| 612 | + | rw := runewidth.RuneWidth(r) | |
| 613 | + | if curW+rw > width { | |
| 614 | + | newLine() | |
| 615 | + | } | |
| 616 | + | cur.WriteRune(r) | |
| 617 | + | curW += rw | |
| 618 | + | } | |
| 619 | + | } else { | |
| 620 | + | cur.WriteString(tk.text) | |
| 621 | + | curW += tk.width | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | if cur.Len() > 0 || len(out) == 0 { | |
| 626 | + | out = append(out, cur.String()) | |
| 627 | + | } | |
| 628 | + | return out | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | func max(a, b int) int { | |
| 632 | + | if a > b { | |
| 633 | + | return a | |
| 634 | + | } | |
| 635 | + | return b | |
| 636 | + | } | |
A internal/tui/image.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "fmt" | |
| 7 | + | "image" | |
| 8 | + | _ "image/gif" | |
| 9 | + | _ "image/jpeg" | |
| 10 | + | _ "image/png" | |
| 11 | + | "os" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // ImageProtocol describes which inline-image escape the current | |
| 16 | + | // terminal understands. | |
| 17 | + | type ImageProtocol int | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | ImageProtocolNone ImageProtocol = iota // no inline images, use text fallback | |
| 21 | + | ImageProtocolITerm2 // iTerm2 proprietary OSC 1337 File= (also: WezTerm) | |
| 22 | + | ImageProtocolKitty // Kitty graphics protocol | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // DetectImageProtocol returns the best inline-image protocol supported | |
| 26 | + | // by the current terminal, or ImageProtocolNone. | |
| 27 | + | // | |
| 28 | + | // The default is to auto-detect: if the terminal advertises iTerm2 or | |
| 29 | + | // Kitty-graphics support, we use it. The ZOT_INLINE_IMAGES env var | |
| 30 | + | // overrides the default: | |
| 31 | + | // ZOT_INLINE_IMAGES=off -> force text fallback | |
| 32 | + | // ZOT_INLINE_IMAGES=iterm -> force iTerm2 protocol | |
| 33 | + | // ZOT_INLINE_IMAGES=kitty -> force Kitty protocol | |
| 34 | + | // ZOT_INLINE_IMAGES=auto -> explicit auto-detect (same as default) | |
| 35 | + | func DetectImageProtocol() ImageProtocol { | |
| 36 | + | switch strings.ToLower(os.Getenv("ZOT_INLINE_IMAGES")) { | |
| 37 | + | case "off", "none", "false", "0": | |
| 38 | + | return ImageProtocolNone | |
| 39 | + | case "iterm", "iterm2": | |
| 40 | + | return ImageProtocolITerm2 | |
| 41 | + | case "kitty": | |
| 42 | + | return ImageProtocolKitty | |
| 43 | + | } | |
| 44 | + | return detectImageProtocolAuto() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // detectImageProtocolAuto returns the best protocol by sniffing the | |
| 48 | + | // current terminal via env vars. Same detection logic as before. | |
| 49 | + | func detectImageProtocolAuto() ImageProtocol { | |
| 50 | + | termProgram := os.Getenv("TERM_PROGRAM") | |
| 51 | + | term := os.Getenv("TERM") | |
| 52 | + | kittyWindow := os.Getenv("KITTY_WINDOW_ID") | |
| 53 | + | ||
| 54 | + | if kittyWindow != "" || strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") { | |
| 55 | + | return ImageProtocolKitty | |
| 56 | + | } | |
| 57 | + | if termProgram == "ghostty" || termProgram == "kitty" { | |
| 58 | + | return ImageProtocolKitty | |
| 59 | + | } | |
| 60 | + | if termProgram == "iTerm.app" || termProgram == "WezTerm" { | |
| 61 | + | return ImageProtocolITerm2 | |
| 62 | + | } | |
| 63 | + | if strings.Contains(strings.ToLower(termProgram), "ghostty") { | |
| 64 | + | return ImageProtocolKitty | |
| 65 | + | } | |
| 66 | + | return ImageProtocolNone | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // RenderInlineImage returns a terminal escape sequence that draws data | |
| 70 | + | // inline. If the protocol is None, returns "" so the caller can fall | |
| 71 | + | // back to a text placeholder. | |
| 72 | + | // | |
| 73 | + | // maxCellsWide caps the rendered width in terminal cells (columns) for | |
| 74 | + | // protocols that honor it. 0 means "let the terminal decide". | |
| 75 | + | func RenderInlineImage(proto ImageProtocol, data []byte, mime string, maxCellsWide int) string { | |
| 76 | + | return RenderInlineImageScaled(proto, data, mime, maxCellsWide, 0) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // RenderInlineImageScaled renders an image with both width and height | |
| 80 | + | // clamps (in terminal cells). Values <= 0 mean "let the terminal decide". | |
| 81 | + | func RenderInlineImageScaled(proto ImageProtocol, data []byte, mime string, maxCellsWide, maxCellsHigh int) string { | |
| 82 | + | switch proto { | |
| 83 | + | case ImageProtocolITerm2: | |
| 84 | + | return renderITerm2(data, maxCellsWide, maxCellsHigh) | |
| 85 | + | case ImageProtocolKitty: | |
| 86 | + | return renderKitty(data, maxCellsWide, maxCellsHigh) | |
| 87 | + | } | |
| 88 | + | return "" | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // renderITerm2 builds an OSC 1337 File= sequence. Works in iTerm2 and WezTerm. | |
| 92 | + | // | |
| 93 | + | // Reference: https://iterm2.com/documentation-images.html | |
| 94 | + | func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |
| 95 | + | b64 := base64.StdEncoding.EncodeToString(data) | |
| 96 | + | var sb strings.Builder | |
| 97 | + | sb.WriteString("\x1b]1337;File=inline=1") | |
| 98 | + | if maxCellsWide > 0 { | |
| 99 | + | fmt.Fprintf(&sb, ";width=%d", maxCellsWide) | |
| 100 | + | } | |
| 101 | + | if maxCellsHigh > 0 { | |
| 102 | + | fmt.Fprintf(&sb, ";height=%d", maxCellsHigh) | |
| 103 | + | } | |
| 104 | + | sb.WriteString(";preserveAspectRatio=1:") | |
| 105 | + | sb.WriteString(b64) | |
| 106 | + | sb.WriteString("\x07") | |
| 107 | + | return sb.String() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // renderKitty builds a Kitty graphics protocol sequence. Supports chunked | |
| 111 | + | // data via the "m" continuation flag; chunk size is 4096 to stay under | |
| 112 | + | // terminal escape-buffer limits. | |
| 113 | + | // | |
| 114 | + | // The Kitty protocol preserves aspect ratio automatically when only one | |
| 115 | + | // of c= (columns) or r= (rows) is set. Setting both causes the image to | |
| 116 | + | // be stretched non-uniformly. We pick whichever constraint is tighter | |
| 117 | + | // for the input image so it fits inside maxCellsWide x maxCellsHigh. | |
| 118 | + | // | |
| 119 | + | // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ | |
| 120 | + | func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |
| 121 | + | b64 := base64.StdEncoding.EncodeToString(data) | |
| 122 | + | const chunk = 4096 | |
| 123 | + | var sb strings.Builder | |
| 124 | + | ||
| 125 | + | // Prefix: delete any previously-placed images so old frames don't | |
| 126 | + | // linger on screen when the chat scrolls past them. | |
| 127 | + | sb.WriteString("\x1b_Ga=d\x1b\\") | |
| 128 | + | ||
| 129 | + | // Pick the most constraining dimension and use only it. Kitty | |
| 130 | + | // preserves aspect ratio when exactly one of c/r is provided. | |
| 131 | + | hdr := "a=T,f=100" | |
| 132 | + | if maxCellsWide > 0 && maxCellsHigh > 0 { | |
| 133 | + | if pxW, pxH := ImageDimensions(data); pxW > 0 && pxH > 0 { | |
| 134 | + | // rows that the native width would produce at maxCellsWide | |
| 135 | + | nativeRows := int(float64(pxH) * float64(maxCellsWide) / float64(pxW) / CellAspectRatio) | |
| 136 | + | if nativeRows > maxCellsHigh { | |
| 137 | + | hdr += fmt.Sprintf(",r=%d", maxCellsHigh) | |
| 138 | + | } else { | |
| 139 | + | hdr += fmt.Sprintf(",c=%d", maxCellsWide) | |
| 140 | + | } | |
| 141 | + | } else { | |
| 142 | + | hdr += fmt.Sprintf(",c=%d", maxCellsWide) | |
| 143 | + | } | |
| 144 | + | } else if maxCellsWide > 0 { | |
| 145 | + | hdr += fmt.Sprintf(",c=%d", maxCellsWide) | |
| 146 | + | } else if maxCellsHigh > 0 { | |
| 147 | + | hdr += fmt.Sprintf(",r=%d", maxCellsHigh) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | for i := 0; i < len(b64); i += chunk { | |
| 151 | + | end := i + chunk | |
| 152 | + | if end > len(b64) { | |
| 153 | + | end = len(b64) | |
| 154 | + | } | |
| 155 | + | more := 1 | |
| 156 | + | if end == len(b64) { | |
| 157 | + | more = 0 | |
| 158 | + | } | |
| 159 | + | if i == 0 { | |
| 160 | + | fmt.Fprintf(&sb, "\x1b_G%s,m=%d;%s\x1b\\", hdr, more, b64[i:end]) | |
| 161 | + | } else { | |
| 162 | + | fmt.Fprintf(&sb, "\x1b_Gm=%d;%s\x1b\\", more, b64[i:end]) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | return sb.String() | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // ImageDimensions returns width and height in pixels, or zeros on error. | |
| 169 | + | // Used for the text fallback so the user sees something useful. | |
| 170 | + | func ImageDimensions(data []byte) (int, int) { | |
| 171 | + | cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) | |
| 172 | + | if err != nil { | |
| 173 | + | return 0, 0 | |
| 174 | + | } | |
| 175 | + | return cfg.Width, cfg.Height | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // CellAspectRatio approximates how many pixel-rows one terminal row | |
| 179 | + | // occupies. Typical monospace cells are ~2x tall as wide; we use 2.0 | |
| 180 | + | // as a safe default. Used to compute the rendered row count when | |
| 181 | + | // scaling an image to fit a cell width. | |
| 182 | + | const CellAspectRatio = 2.0 | |
| 183 | + | ||
| 184 | + | // RowsForInlineImage returns the number of terminal rows an image | |
| 185 | + | // rendered at cellsWide columns will occupy, preserving aspect ratio. | |
| 186 | + | // Clamped to maxRows. Returns 0 if the image cannot be decoded. | |
| 187 | + | func RowsForInlineImage(data []byte, cellsWide, maxRows int) int { | |
| 188 | + | w, h := ImageDimensions(data) | |
| 189 | + | if w <= 0 || h <= 0 || cellsWide <= 0 { | |
| 190 | + | return 0 | |
| 191 | + | } | |
| 192 | + | // pixels per cell (horizontal) | |
| 193 | + | // scaleX = imageWidthPx / cellsWide | |
| 194 | + | // rendered height in cells = imageHeightPx / (scaleX * CellAspectRatio) | |
| 195 | + | scaleX := float64(w) / float64(cellsWide) | |
| 196 | + | rows := int(float64(h) / (scaleX * CellAspectRatio)) | |
| 197 | + | if rows < 1 { | |
| 198 | + | rows = 1 | |
| 199 | + | } | |
| 200 | + | if maxRows > 0 && rows > maxRows { | |
| 201 | + | rows = maxRows | |
| 202 | + | } | |
| 203 | + | return rows | |
| 204 | + | } | |
A internal/tui/input.go +285 -0
| @@ -0,0 +1,285 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Key is a parsed keypress. | |
| 9 | + | type Key struct { | |
| 10 | + | Kind KeyKind | |
| 11 | + | Rune rune // for KeyRune | |
| 12 | + | Paste string // for KeyPaste | |
| 13 | + | Ctrl bool | |
| 14 | + | Alt bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type KeyKind int | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | KeyRune KeyKind = iota | |
| 21 | + | KeyEnter | |
| 22 | + | KeyBackspace | |
| 23 | + | KeyTab | |
| 24 | + | KeyShiftTab | |
| 25 | + | KeyEsc | |
| 26 | + | KeyUp | |
| 27 | + | KeyDown | |
| 28 | + | KeyLeft | |
| 29 | + | KeyRight | |
| 30 | + | KeyHome | |
| 31 | + | KeyEnd | |
| 32 | + | KeyPageUp | |
| 33 | + | KeyPageDown | |
| 34 | + | KeyDelete | |
| 35 | + | KeyCtrlC | |
| 36 | + | KeyCtrlD | |
| 37 | + | KeyCtrlL | |
| 38 | + | KeyCtrlU | |
| 39 | + | KeyCtrlK | |
| 40 | + | KeyCtrlA | |
| 41 | + | KeyCtrlE | |
| 42 | + | KeyCtrlW | |
| 43 | + | KeyPaste | |
| 44 | + | KeyUnknown | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // Reader parses a byte stream into Key events. It understands basic | |
| 48 | + | // xterm escape sequences and bracketed paste. | |
| 49 | + | type Reader struct { | |
| 50 | + | src func() (byte, error) | |
| 51 | + | peek func(time.Duration) (byte, bool, error) // optional; may be nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // NewReader returns a Reader that pulls bytes from read. | |
| 55 | + | func NewReader(read func() (byte, error)) *Reader { return &Reader{src: read} } | |
| 56 | + | ||
| 57 | + | // NewReaderWithPeek returns a Reader that pulls bytes from read and uses | |
| 58 | + | // peek to disambiguate bare Esc from the start of an escape sequence. | |
| 59 | + | func NewReaderWithPeek(read func() (byte, error), peek func(time.Duration) (byte, bool, error)) *Reader { | |
| 60 | + | return &Reader{src: read, peek: peek} | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Read returns the next parsed Key. | |
| 64 | + | func (r *Reader) Read() (Key, error) { | |
| 65 | + | b, err := r.src() | |
| 66 | + | if err != nil { | |
| 67 | + | return Key{}, err | |
| 68 | + | } | |
| 69 | + | switch { | |
| 70 | + | case b == 0x03: | |
| 71 | + | return Key{Kind: KeyCtrlC}, nil | |
| 72 | + | case b == 0x04: | |
| 73 | + | return Key{Kind: KeyCtrlD}, nil | |
| 74 | + | case b == 0x0c: | |
| 75 | + | return Key{Kind: KeyCtrlL}, nil | |
| 76 | + | case b == 0x15: | |
| 77 | + | return Key{Kind: KeyCtrlU}, nil | |
| 78 | + | case b == 0x0b: | |
| 79 | + | return Key{Kind: KeyCtrlK}, nil | |
| 80 | + | case b == 0x01: | |
| 81 | + | return Key{Kind: KeyCtrlA}, nil | |
| 82 | + | case b == 0x05: | |
| 83 | + | return Key{Kind: KeyCtrlE}, nil | |
| 84 | + | case b == 0x17: | |
| 85 | + | return Key{Kind: KeyCtrlW}, nil | |
| 86 | + | case b == '\r', b == '\n': | |
| 87 | + | return Key{Kind: KeyEnter}, nil | |
| 88 | + | case b == '\t': | |
| 89 | + | return Key{Kind: KeyTab}, nil | |
| 90 | + | case b == 0x7f, b == 0x08: | |
| 91 | + | return Key{Kind: KeyBackspace}, nil | |
| 92 | + | case b == 0x1b: | |
| 93 | + | return r.readEscape() | |
| 94 | + | case b < 0x20: | |
| 95 | + | return Key{Kind: KeyUnknown}, nil | |
| 96 | + | } | |
| 97 | + | // UTF-8 multibyte? | |
| 98 | + | if b < 0x80 { | |
| 99 | + | return Key{Kind: KeyRune, Rune: rune(b)}, nil | |
| 100 | + | } | |
| 101 | + | // Decode UTF-8 (up to 4 bytes). | |
| 102 | + | n := utf8Len(b) | |
| 103 | + | buf := []byte{b} | |
| 104 | + | for i := 1; i < n; i++ { | |
| 105 | + | bb, err := r.src() | |
| 106 | + | if err != nil { | |
| 107 | + | return Key{}, err | |
| 108 | + | } | |
| 109 | + | buf = append(buf, bb) | |
| 110 | + | } | |
| 111 | + | rn, _ := decodeRune(buf) | |
| 112 | + | return Key{Kind: KeyRune, Rune: rn}, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func utf8Len(b byte) int { | |
| 116 | + | switch { | |
| 117 | + | case b&0xe0 == 0xc0: | |
| 118 | + | return 2 | |
| 119 | + | case b&0xf0 == 0xe0: | |
| 120 | + | return 3 | |
| 121 | + | case b&0xf8 == 0xf0: | |
| 122 | + | return 4 | |
| 123 | + | } | |
| 124 | + | return 1 | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func decodeRune(b []byte) (rune, int) { | |
| 128 | + | // Minimal decoder; invalid runes become U+FFFD. | |
| 129 | + | if len(b) == 1 { | |
| 130 | + | return rune(b[0]), 1 | |
| 131 | + | } | |
| 132 | + | var r rune | |
| 133 | + | switch len(b) { | |
| 134 | + | case 2: | |
| 135 | + | r = rune(b[0]&0x1f)<<6 | rune(b[1]&0x3f) | |
| 136 | + | case 3: | |
| 137 | + | r = rune(b[0]&0x0f)<<12 | rune(b[1]&0x3f)<<6 | rune(b[2]&0x3f) | |
| 138 | + | case 4: | |
| 139 | + | r = rune(b[0]&0x07)<<18 | rune(b[1]&0x3f)<<12 | rune(b[2]&0x3f)<<6 | rune(b[3]&0x3f) | |
| 140 | + | default: | |
| 141 | + | r = 0xFFFD | |
| 142 | + | } | |
| 143 | + | return r, len(b) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // readEscape handles sequences starting with 0x1b. | |
| 147 | + | func (r *Reader) readEscape() (Key, error) { | |
| 148 | + | // Bare ESC: maybe followed by another byte within a short window. | |
| 149 | + | b, have, err := r.readEscapeNext(50 * time.Millisecond) | |
| 150 | + | if err != nil || !have { | |
| 151 | + | return Key{Kind: KeyEsc}, nil | |
| 152 | + | } | |
| 153 | + | switch b { | |
| 154 | + | case '[': | |
| 155 | + | return r.readCSI() | |
| 156 | + | case 'O': | |
| 157 | + | // SS3 sequences (function keys in some terminals). | |
| 158 | + | c, err := r.src() | |
| 159 | + | if err != nil { | |
| 160 | + | return Key{}, err | |
| 161 | + | } | |
| 162 | + | switch c { | |
| 163 | + | case 'H': | |
| 164 | + | return Key{Kind: KeyHome}, nil | |
| 165 | + | case 'F': | |
| 166 | + | return Key{Kind: KeyEnd}, nil | |
| 167 | + | } | |
| 168 | + | return Key{Kind: KeyUnknown}, nil | |
| 169 | + | case 0x7f, 0x08: | |
| 170 | + | // Alt+Backspace (Option+Delete on macOS) — most terminals send | |
| 171 | + | // ESC + DEL for this. Surface as a dedicated "alt backspace" | |
| 172 | + | // so the editor can map it to delete-word. | |
| 173 | + | return Key{Kind: KeyBackspace, Alt: true}, nil | |
| 174 | + | case 'b': | |
| 175 | + | // Emacs-style word-left, also emitted by some terminals for | |
| 176 | + | // Option+LeftArrow. | |
| 177 | + | return Key{Kind: KeyLeft, Alt: true}, nil | |
| 178 | + | case 'f': | |
| 179 | + | // Emacs-style word-right, also emitted for Option+RightArrow. | |
| 180 | + | return Key{Kind: KeyRight, Alt: true}, nil | |
| 181 | + | default: | |
| 182 | + | // Alt+<char> | |
| 183 | + | if b < 0x80 { | |
| 184 | + | return Key{Kind: KeyRune, Rune: rune(b), Alt: true}, nil | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return Key{Kind: KeyUnknown}, nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // readEscapeNext tries to read one byte within d. If peek is available | |
| 191 | + | // we use it (true non-blocking). Otherwise we fall back to a blocking | |
| 192 | + | // read, which means bare Esc is only detected after the next keystroke. | |
| 193 | + | func (r *Reader) readEscapeNext(d time.Duration) (byte, bool, error) { | |
| 194 | + | if r.peek != nil { | |
| 195 | + | return r.peek(d) | |
| 196 | + | } | |
| 197 | + | b, err := r.src() | |
| 198 | + | if err != nil { | |
| 199 | + | return 0, false, err | |
| 200 | + | } | |
| 201 | + | return b, true, nil | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // readCSI parses a CSI sequence after ESC [. | |
| 205 | + | func (r *Reader) readCSI() (Key, error) { | |
| 206 | + | var params []byte | |
| 207 | + | for { | |
| 208 | + | c, err := r.src() | |
| 209 | + | if err != nil { | |
| 210 | + | return Key{}, err | |
| 211 | + | } | |
| 212 | + | if c >= 0x30 && c <= 0x3f { | |
| 213 | + | params = append(params, c) | |
| 214 | + | continue | |
| 215 | + | } | |
| 216 | + | // Final byte. | |
| 217 | + | return r.dispatchCSI(string(params), c), nil | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (r *Reader) dispatchCSI(params string, final byte) Key { | |
| 222 | + | // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values | |
| 223 | + | // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. | |
| 224 | + | var alt bool | |
| 225 | + | if params != "" { | |
| 226 | + | if i := strings.IndexByte(params, ';'); i >= 0 { | |
| 227 | + | mod := params[i+1:] | |
| 228 | + | if mod == "3" || mod == "4" || mod == "7" || mod == "8" { | |
| 229 | + | // 3=Alt, 4=Shift+Alt, 7=Ctrl+Alt, 8=Ctrl+Shift+Alt | |
| 230 | + | alt = true | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | switch final { | |
| 235 | + | case 'A': | |
| 236 | + | return Key{Kind: KeyUp, Alt: alt} | |
| 237 | + | case 'B': | |
| 238 | + | return Key{Kind: KeyDown, Alt: alt} | |
| 239 | + | case 'C': | |
| 240 | + | return Key{Kind: KeyRight, Alt: alt} | |
| 241 | + | case 'D': | |
| 242 | + | return Key{Kind: KeyLeft, Alt: alt} | |
| 243 | + | case 'H': | |
| 244 | + | return Key{Kind: KeyHome} | |
| 245 | + | case 'F': | |
| 246 | + | return Key{Kind: KeyEnd} | |
| 247 | + | case 'Z': | |
| 248 | + | return Key{Kind: KeyShiftTab} | |
| 249 | + | case '~': | |
| 250 | + | switch params { | |
| 251 | + | case "3": | |
| 252 | + | return Key{Kind: KeyDelete} | |
| 253 | + | case "5": | |
| 254 | + | return Key{Kind: KeyPageUp} | |
| 255 | + | case "6": | |
| 256 | + | return Key{Kind: KeyPageDown} | |
| 257 | + | case "200": | |
| 258 | + | // Start of bracketed paste. | |
| 259 | + | return r.readPaste() | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | return Key{Kind: KeyUnknown} | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // readPaste reads until ESC [ 2 0 1 ~ and returns the pasted text. | |
| 266 | + | func (r *Reader) readPaste() Key { | |
| 267 | + | var sb strings.Builder | |
| 268 | + | const end = "\x1b[201~" | |
| 269 | + | tail := make([]byte, 0, len(end)) | |
| 270 | + | for { | |
| 271 | + | b, err := r.src() | |
| 272 | + | if err != nil { | |
| 273 | + | break | |
| 274 | + | } | |
| 275 | + | tail = append(tail, b) | |
| 276 | + | if len(tail) > len(end) { | |
| 277 | + | sb.WriteByte(tail[0]) | |
| 278 | + | tail = tail[1:] | |
| 279 | + | } | |
| 280 | + | if string(tail) == end { | |
| 281 | + | break | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | return Key{Kind: KeyPaste, Paste: sb.String()} | |
| 285 | + | } | |
A internal/tui/markdown.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // RenderMarkdown renders a small subset of Markdown to styled terminal | |
| 9 | + | // text using theme colors. Supported: headings, bold, italic, inline | |
| 10 | + | // code, fenced code blocks, bullet lists, numbered lists, blockquotes. | |
| 11 | + | // Not supported: tables, links with complex formatting, HTML. | |
| 12 | + | // | |
| 13 | + | // width is used to draw horizontal rules (e.g. around code fences). | |
| 14 | + | // Pass 0 to use a reasonable fallback. | |
| 15 | + | func RenderMarkdown(src string, th Theme, width int) string { | |
| 16 | + | if width <= 0 { | |
| 17 | + | width = 80 | |
| 18 | + | } | |
| 19 | + | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 20 | + | ||
| 21 | + | lines := strings.Split(src, "\n") | |
| 22 | + | var out strings.Builder | |
| 23 | + | inFence := false | |
| 24 | + | fenceIndent := "" | |
| 25 | + | for _, line := range lines { | |
| 26 | + | trim := strings.TrimLeft(line, " ") | |
| 27 | + | if strings.HasPrefix(trim, "```") { | |
| 28 | + | if inFence { | |
| 29 | + | // closing fence: emit bottom rule | |
| 30 | + | inFence = false | |
| 31 | + | out.WriteString(rule + "\n") | |
| 32 | + | } else { | |
| 33 | + | // opening fence: emit top rule | |
| 34 | + | inFence = true | |
| 35 | + | fenceIndent = line[:len(line)-len(trim)] | |
| 36 | + | out.WriteString(rule + "\n") | |
| 37 | + | } | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | if inFence { | |
| 41 | + | if strings.HasPrefix(line, fenceIndent) { | |
| 42 | + | line = line[len(fenceIndent):] | |
| 43 | + | } | |
| 44 | + | out.WriteString(th.FG256(th.Accent, line) + "\n") | |
| 45 | + | continue | |
| 46 | + | } | |
| 47 | + | // Headings. | |
| 48 | + | if m := headingRE.FindStringSubmatch(line); m != nil { | |
| 49 | + | level := len(m[1]) | |
| 50 | + | body := strings.TrimSpace(m[2]) | |
| 51 | + | prefix := strings.Repeat("#", level) + " " | |
| 52 | + | out.WriteString(Bold(th.FG256(th.Accent, prefix+body)) + "\n") | |
| 53 | + | continue | |
| 54 | + | } | |
| 55 | + | // Blockquote. | |
| 56 | + | if strings.HasPrefix(trim, "> ") { | |
| 57 | + | body := strings.TrimPrefix(trim, "> ") | |
| 58 | + | out.WriteString(th.FG256(th.Muted, "┃ ") + renderInline(body, th) + "\n") | |
| 59 | + | continue | |
| 60 | + | } | |
| 61 | + | // Bullet list. | |
| 62 | + | if m := bulletRE.FindStringSubmatch(line); m != nil { | |
| 63 | + | indent, body := m[1], m[2] | |
| 64 | + | out.WriteString(indent + th.FG256(th.Accent, "• ") + renderInline(body, th) + "\n") | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | // Numbered list. | |
| 68 | + | if m := numberRE.FindStringSubmatch(line); m != nil { | |
| 69 | + | indent, num, body := m[1], m[2], m[3] | |
| 70 | + | out.WriteString(indent + th.FG256(th.Accent, num+". ") + renderInline(body, th) + "\n") | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | out.WriteString(renderInline(line, th) + "\n") | |
| 74 | + | } | |
| 75 | + | return strings.TrimRight(out.String(), "\n") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | var ( | |
| 79 | + | headingRE = regexp.MustCompile(`^(#{1,6})\s+(.*)$`) | |
| 80 | + | bulletRE = regexp.MustCompile(`^(\s*)[-*+]\s+(.*)$`) | |
| 81 | + | numberRE = regexp.MustCompile(`^(\s*)(\d+)\.\s+(.*)$`) | |
| 82 | + | ||
| 83 | + | boldRE = regexp.MustCompile(`\*\*([^*]+)\*\*`) | |
| 84 | + | italRE = regexp.MustCompile(`\*([^*]+)\*`) | |
| 85 | + | codeRE = regexp.MustCompile("`([^`]+)`") | |
| 86 | + | ) | |
| 87 | + | ||
| 88 | + | func renderInline(s string, th Theme) string { | |
| 89 | + | s = codeRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 90 | + | inner := m[1 : len(m)-1] | |
| 91 | + | return th.FG256(th.Accent, inner) | |
| 92 | + | }) | |
| 93 | + | s = boldRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 94 | + | inner := m[2 : len(m)-2] | |
| 95 | + | return Bold(inner) | |
| 96 | + | }) | |
| 97 | + | s = italRE.ReplaceAllStringFunc(s, func(m string) string { | |
| 98 | + | inner := m[1 : len(m)-1] | |
| 99 | + | return Italic(inner) | |
| 100 | + | }) | |
| 101 | + | return s | |
| 102 | + | } | |
A internal/tui/render.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Renderer maintains a previous frame and writes only the lines that | |
| 9 | + | // changed on each Draw(). Callers pass a full target frame (slice of | |
| 10 | + | // styled lines, already wrapped to width). | |
| 11 | + | type Renderer struct { | |
| 12 | + | out io.Writer | |
| 13 | + | prev []string | |
| 14 | + | rows int // terminal rows | |
| 15 | + | cols int // terminal cols | |
| 16 | + | ||
| 17 | + | // Cursor position after last draw (for placing input cursor). | |
| 18 | + | cursorRow int | |
| 19 | + | cursorCol int | |
| 20 | + | ||
| 21 | + | // hideCursor when true prevents ShowCursor from being emitted. | |
| 22 | + | hideCursor bool | |
| 23 | + | ||
| 24 | + | // prevHadImage tracks whether the previous frame contained an | |
| 25 | + | // inline-image escape so we can force a full clear+repaint whenever | |
| 26 | + | // the image set changes. Only matters when inline images are | |
| 27 | + | // enabled via ZOT_INLINE_IMAGES; defaults to false. | |
| 28 | + | prevHadImage bool | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // NewRenderer returns a renderer that writes to out. | |
| 32 | + | func NewRenderer(out io.Writer) *Renderer { | |
| 33 | + | return &Renderer{out: out} | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Resize tells the renderer the current terminal size. | |
| 37 | + | func (r *Renderer) Resize(cols, rows int) { | |
| 38 | + | if cols != r.cols || rows != r.rows { | |
| 39 | + | r.cols = cols | |
| 40 | + | r.rows = rows | |
| 41 | + | r.prev = nil | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Clear forces a full repaint on the next Draw and clears the screen. | |
| 46 | + | func (r *Renderer) Clear() { | |
| 47 | + | r.prev = nil | |
| 48 | + | _, _ = io.WriteString(r.out, SeqClearScreen) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Draw updates the terminal so that the visible frame ends with the | |
| 52 | + | // given lines (bottom-aligned). cursorRow/cursorCol are offsets within | |
| 53 | + | // the lines slice indicating where to place the terminal cursor; use | |
| 54 | + | // -1 to hide it. | |
| 55 | + | // containsImageEscape reports whether the line carries an inline-image | |
| 56 | + | // escape we must repaint rather than diff against the previous frame. | |
| 57 | + | func containsImageEscape(s string) bool { | |
| 58 | + | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |
| 62 | + | if r.cols == 0 || r.rows == 0 { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | // Bottom-align: only the last r.rows lines are visible. | |
| 66 | + | visible := lines | |
| 67 | + | if len(visible) > r.rows { | |
| 68 | + | visible = visible[len(visible)-r.rows:] | |
| 69 | + | cursorRow -= len(lines) - len(visible) | |
| 70 | + | } | |
| 71 | + | // Pad to r.rows with empty lines at the top. | |
| 72 | + | frame := make([]string, r.rows) | |
| 73 | + | top := r.rows - len(visible) | |
| 74 | + | for i := 0; i < top; i++ { | |
| 75 | + | frame[i] = "" | |
| 76 | + | } | |
| 77 | + | for i, line := range visible { | |
| 78 | + | frame[top+i] = line | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | var w strings.Builder | |
| 82 | + | w.WriteString(SeqSynchronizedOn) | |
| 83 | + | w.WriteString(SeqHideCursor) | |
| 84 | + | ||
| 85 | + | // When inline images are in play we always full-repaint (clear | |
| 86 | + | // screen first, then rewrite every row). Terminals manage image | |
| 87 | + | // pixels in a layer we cannot diff against, so the per-line cache | |
| 88 | + | // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES; | |
| 89 | + | // the common code path below is the fast cached diff. | |
| 90 | + | curHasImage := false | |
| 91 | + | for _, l := range frame { | |
| 92 | + | if containsImageEscape(l) { | |
| 93 | + | curHasImage = true | |
| 94 | + | break | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | forceAll := curHasImage || r.prevHadImage | |
| 98 | + | if forceAll { | |
| 99 | + | w.WriteString(SeqClearScreen) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | full := r.prev == nil || len(r.prev) != r.rows | |
| 103 | + | for i := 0; i < r.rows; i++ { | |
| 104 | + | if full || forceAll || r.prev[i] != frame[i] { | |
| 105 | + | w.WriteString(MoveTo(i+1, 1)) | |
| 106 | + | w.WriteString(SeqClearLine) | |
| 107 | + | w.WriteString(frame[i]) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | if cursorRow >= 0 { | |
| 112 | + | absRow := top + cursorRow + 1 | |
| 113 | + | absCol := cursorCol + 1 | |
| 114 | + | if absRow >= 1 && absRow <= r.rows { | |
| 115 | + | w.WriteString(MoveTo(absRow, absCol)) | |
| 116 | + | w.WriteString(SeqShowCursor) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | w.WriteString(SeqSynchronizedOff) | |
| 120 | + | ||
| 121 | + | _, _ = io.WriteString(r.out, w.String()) | |
| 122 | + | ||
| 123 | + | r.prev = frame | |
| 124 | + | r.prevHadImage = curHasImage | |
| 125 | + | r.cursorRow = cursorRow | |
| 126 | + | r.cursorCol = cursorCol | |
| 127 | + | } | |
A internal/tui/resize_unix.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | //go:build !windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "os/signal" | |
| 8 | + | "syscall" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func (p *ProcTerm) installResizeHandler() { | |
| 13 | + | ch := make(chan os.Signal, 1) | |
| 14 | + | signal.Notify(ch, syscall.SIGWINCH) | |
| 15 | + | go func() { | |
| 16 | + | for range ch { | |
| 17 | + | for _, cb := range p.resizeCBs { | |
| 18 | + | cb() | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | }() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (p *ProcTerm) SetNonblock(enable bool) error { | |
| 25 | + | return syscall.SetNonblock(int(p.in.Fd()), enable) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // peekStdin polls the stdin fd for up to d; if a byte is ready, reads | |
| 29 | + | // and returns it. Returns (0, false, nil) on timeout. | |
| 30 | + | func peekStdin(in *os.File, d time.Duration) (byte, bool, error) { | |
| 31 | + | fd := int(in.Fd()) | |
| 32 | + | var rset syscall.FdSet | |
| 33 | + | fdSet(&rset, fd) | |
| 34 | + | tv := syscall.Timeval{ | |
| 35 | + | Sec: int64(d / time.Second), | |
| 36 | + | Usec: int32((d % time.Second) / time.Microsecond), | |
| 37 | + | } | |
| 38 | + | err := syscall.Select(fd+1, &rset, nil, nil, &tv) | |
| 39 | + | if err != nil { | |
| 40 | + | if err == syscall.EINTR { | |
| 41 | + | return 0, false, nil | |
| 42 | + | } | |
| 43 | + | return 0, false, err | |
| 44 | + | } | |
| 45 | + | if !fdIsSet(&rset, fd) { | |
| 46 | + | return 0, false, nil | |
| 47 | + | } | |
| 48 | + | var b [1]byte | |
| 49 | + | _, rerr := in.Read(b[:]) | |
| 50 | + | if rerr != nil { | |
| 51 | + | return 0, false, rerr | |
| 52 | + | } | |
| 53 | + | return b[0], true, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // fdSet sets bit fd in the fd_set. Plain Go implementation so we don't | |
| 57 | + | // need platform-specific helpers from golang.org/x/sys. | |
| 58 | + | func fdSet(set *syscall.FdSet, fd int) { | |
| 59 | + | set.Bits[fd/64] |= 1 << (uint(fd) % 64) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func fdIsSet(set *syscall.FdSet, fd int) bool { | |
| 63 | + | return set.Bits[fd/64]&(1<<(uint(fd)%64)) != 0 | |
| 64 | + | } | |
A internal/tui/resize_windows.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | //go:build windows | |
| 2 | + | ||
| 3 | + | package tui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Windows doesn't have SIGWINCH; resize events are signaled via input | |
| 11 | + | // records. For v1 we just ignore resize and rely on the next render | |
| 12 | + | // cycle to pick up the new size via term.GetSize. | |
| 13 | + | func (p *ProcTerm) installResizeHandler() {} | |
| 14 | + | ||
| 15 | + | func (p *ProcTerm) SetNonblock(enable bool) error { return nil } | |
| 16 | + | ||
| 17 | + | // peekStdin on Windows: no non-blocking primitive in v1; fall through | |
| 18 | + | // to a blocking read. This means bare Esc on Windows is only detected | |
| 19 | + | // after the next keystroke. Good enough for v1. | |
| 20 | + | func peekStdin(in *os.File, _ time.Duration) (byte, bool, error) { | |
| 21 | + | var b [1]byte | |
| 22 | + | _, err := in.Read(b[:]) | |
| 23 | + | if err != nil { | |
| 24 | + | return 0, false, err | |
| 25 | + | } | |
| 26 | + | return b[0], true, nil | |
| 27 | + | } | |
A internal/tui/terminal.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "os" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "golang.org/x/term" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Terminal abstracts the real terminal for tests. | |
| 12 | + | type Terminal interface { | |
| 13 | + | io.Writer | |
| 14 | + | // Size returns (cols, rows). | |
| 15 | + | Size() (int, int) | |
| 16 | + | // OnResize registers a callback invoked on SIGWINCH (best effort). | |
| 17 | + | OnResize(func()) | |
| 18 | + | // EnterRaw puts the tty into raw mode. Returns a restore func. | |
| 19 | + | EnterRaw() (restore func() error, err error) | |
| 20 | + | // ReadByte reads one byte of input. Blocks. | |
| 21 | + | ReadByte() (byte, error) | |
| 22 | + | // PeekByteTimeout reads one byte of input but returns (0, false, nil) | |
| 23 | + | // if no byte arrives within the timeout. Used to disambiguate bare | |
| 24 | + | // Esc from the start of an escape sequence. | |
| 25 | + | PeekByteTimeout(time.Duration) (byte, bool, error) | |
| 26 | + | // SetNonblock sets stdin to non-blocking mode (used by paste handling). | |
| 27 | + | // May be a no-op on some platforms. | |
| 28 | + | SetNonblock(bool) error | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ProcTerm is a Terminal bound to the current process's tty. | |
| 32 | + | type ProcTerm struct { | |
| 33 | + | out *os.File | |
| 34 | + | in *os.File | |
| 35 | + | resizeCBs []func() | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // NewProcTerm returns a Terminal bound to stdin/stdout. | |
| 39 | + | func NewProcTerm() *ProcTerm { | |
| 40 | + | return &ProcTerm{out: os.Stdout, in: os.Stdin} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) } | |
| 44 | + | ||
| 45 | + | func (p *ProcTerm) Size() (int, int) { | |
| 46 | + | w, h, err := term.GetSize(int(p.out.Fd())) | |
| 47 | + | if err != nil || w <= 0 || h <= 0 { | |
| 48 | + | return 80, 24 | |
| 49 | + | } | |
| 50 | + | return w, h | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (p *ProcTerm) OnResize(fn func()) { | |
| 54 | + | p.resizeCBs = append(p.resizeCBs, fn) | |
| 55 | + | p.installResizeHandler() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (p *ProcTerm) EnterRaw() (func() error, error) { | |
| 59 | + | fd := int(p.in.Fd()) | |
| 60 | + | if !term.IsTerminal(fd) { | |
| 61 | + | return func() error { return nil }, nil | |
| 62 | + | } | |
| 63 | + | st, err := term.MakeRaw(fd) | |
| 64 | + | if err != nil { | |
| 65 | + | return nil, err | |
| 66 | + | } | |
| 67 | + | return func() error { return term.Restore(fd, st) }, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (p *ProcTerm) ReadByte() (byte, error) { | |
| 71 | + | var b [1]byte | |
| 72 | + | _, err := p.in.Read(b[:]) | |
| 73 | + | return b[0], err | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // PeekByteTimeout uses a platform-specific non-blocking read to decide | |
| 77 | + | // whether another byte is available within d. If not, returns (0, false, nil). | |
| 78 | + | func (p *ProcTerm) PeekByteTimeout(d time.Duration) (byte, bool, error) { | |
| 79 | + | return peekStdin(p.in, d) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // ---- terminal control sequences ---- | |
| 83 | + | ||
| 84 | + | // HideCursor, ShowCursor, ClearScreen, BracketedPasteOn/Off, etc. | |
| 85 | + | const ( | |
| 86 | + | SeqHideCursor = "\x1b[?25l" | |
| 87 | + | SeqShowCursor = "\x1b[?25h" | |
| 88 | + | SeqClearScreen = "\x1b[2J\x1b[H" | |
| 89 | + | SeqClearLine = "\x1b[2K" | |
| 90 | + | SeqBracketedPasteOn = "\x1b[?2004h" | |
| 91 | + | SeqBracketedPasteOff = "\x1b[?2004l" | |
| 92 | + | SeqAltScreenOn = "\x1b[?1049h" | |
| 93 | + | SeqAltScreenOff = "\x1b[?1049l" | |
| 94 | + | SeqSynchronizedOn = "\x1b[?2026h" | |
| 95 | + | SeqSynchronizedOff = "\x1b[?2026l" | |
| 96 | + | ) | |
| 97 | + | ||
| 98 | + | // MoveTo moves the cursor to 1-indexed (row, col). | |
| 99 | + | func MoveTo(row, col int) string { | |
| 100 | + | return "\x1b[" + itoa(row) + ";" + itoa(col) + "H" | |
| 101 | + | } | |
A internal/tui/theme.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Package tui is a small terminal UI: raw-mode input, differential | |
| 2 | + | // line renderer, multi-line editor, and chat view. No external TUI | |
| 3 | + | // framework; just ANSI escape codes. | |
| 4 | + | package tui | |
| 5 | + | ||
| 6 | + | import "strings" | |
| 7 | + | ||
| 8 | + | // ANSI 256-color palette used by zot. Defined as numeric codes so we | |
| 9 | + | // can swap themes without changing any render code. | |
| 10 | + | type Theme struct { | |
| 11 | + | FG int | |
| 12 | + | Muted int | |
| 13 | + | Accent int | |
| 14 | + | User int // label color for the user role | |
| 15 | + | Assistant int // label color for the zot role | |
| 16 | + | Tool int | |
| 17 | + | ToolOut int | |
| 18 | + | Error int | |
| 19 | + | Warning int | |
| 20 | + | Spinner int // spinner + funny working line | |
| 21 | + | SelectionBG int // background for highlighted rows | |
| 22 | + | SelectionFG int // foreground for highlighted rows | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | var Dark = Theme{ | |
| 26 | + | FG: 253, | |
| 27 | + | Muted: 244, | |
| 28 | + | Accent: 111, // soft blue | |
| 29 | + | User: 180, // warm tan | |
| 30 | + | Assistant: 117, // bright cyan — the zot label color | |
| 31 | + | Tool: 114, // green | |
| 32 | + | ToolOut: 245, | |
| 33 | + | Error: 203, | |
| 34 | + | Warning: 214, | |
| 35 | + | Spinner: 183, // soft purple | |
| 36 | + | SelectionBG: 24, // deep blue background | |
| 37 | + | SelectionFG: 231, // near-white foreground | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var Light = Theme{ | |
| 41 | + | FG: 236, | |
| 42 | + | Muted: 244, | |
| 43 | + | Accent: 33, | |
| 44 | + | User: 94, | |
| 45 | + | Assistant: 31, // deep cyan | |
| 46 | + | Tool: 28, | |
| 47 | + | ToolOut: 240, | |
| 48 | + | Error: 160, | |
| 49 | + | Warning: 166, | |
| 50 | + | Spinner: 91, // purple | |
| 51 | + | SelectionBG: 153, // light blue | |
| 52 | + | SelectionFG: 232, // near-black | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // FG256 wraps s in foreground color c using ANSI 256-color SGR. | |
| 56 | + | func (t Theme) FG256(c int, s string) string { | |
| 57 | + | return sgrFG(c) + s + reset | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Highlight paints s with the theme's selection colors (foreground + | |
| 61 | + | // background). The caller is responsible for padding s to the desired | |
| 62 | + | // width; styling alone does not extend the background past content. | |
| 63 | + | func (t Theme) Highlight(s string) string { | |
| 64 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PadHighlight styles s and extends the selection background to the | |
| 68 | + | // full terminal width so the highlight is a full row, not just a | |
| 69 | + | // rectangle around the text. | |
| 70 | + | func (t Theme) PadHighlight(s string, width int) string { | |
| 71 | + | visible := visibleWidth(s) | |
| 72 | + | if visible < width { | |
| 73 | + | s += strings.Repeat(" ", width-visible) | |
| 74 | + | } | |
| 75 | + | return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Bold wraps s in bold SGR. | |
| 79 | + | func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" } | |
| 80 | + | ||
| 81 | + | // Dim wraps s in dim SGR. | |
| 82 | + | func Dim(s string) string { return "\x1b[2m" + s + "\x1b[22m" } | |
| 83 | + | ||
| 84 | + | // Italic wraps s in italic SGR. | |
| 85 | + | func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" } | |
| 86 | + | ||
| 87 | + | const reset = "\x1b[0m" | |
| 88 | + | ||
| 89 | + | func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" } | |
| 90 | + | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" } | |
| 91 | + | ||
| 92 | + | // small itoa to avoid pulling strconv into this hot path twice. | |
| 93 | + | func itoa(n int) string { | |
| 94 | + | if n == 0 { | |
| 95 | + | return "0" | |
| 96 | + | } | |
| 97 | + | buf := [4]byte{} | |
| 98 | + | i := len(buf) | |
| 99 | + | for n > 0 { | |
| 100 | + | i-- | |
| 101 | + | buf[i] = byte('0' + n%10) | |
| 102 | + | n /= 10 | |
| 103 | + | } | |
| 104 | + | return string(buf[i:]) | |
| 105 | + | } | |
A internal/tui/view.go +409 -0
| @@ -0,0 +1,409 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // osUserHomeDir is aliased so the test file can swap it. | |
| 13 | + | var osUserHomeDir = os.UserHomeDir | |
| 14 | + | ||
| 15 | + | // View turns a transcript + live state into a slice of styled lines, | |
| 16 | + | // already wrapped to width. | |
| 17 | + | type View struct { | |
| 18 | + | Theme Theme | |
| 19 | + | ImageProto ImageProtocol // how to render inline images in this terminal | |
| 20 | + | Messages []provider.Message | |
| 21 | + | Streaming string // current assistant text delta | |
| 22 | + | StreamingActive bool | |
| 23 | + | ToolCalls []ToolCallView // tool calls in flight or completed | |
| 24 | + | StatusLine string | |
| 25 | + | Err string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ToolCallView is a pending tool invocation plus optional result. | |
| 29 | + | type ToolCallView struct { | |
| 30 | + | ID string | |
| 31 | + | Name string | |
| 32 | + | Args string // rendered argument summary | |
| 33 | + | Result string // rendered result preview (truncated) | |
| 34 | + | Error bool | |
| 35 | + | Done bool | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Build returns the chat log lines for the given width. | |
| 39 | + | func (v *View) Build(width int) []string { | |
| 40 | + | var out []string | |
| 41 | + | for _, m := range v.Messages { | |
| 42 | + | out = append(out, v.renderMessage(m, width)...) | |
| 43 | + | out = append(out, "") | |
| 44 | + | } | |
| 45 | + | if v.StreamingActive { | |
| 46 | + | out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) | |
| 47 | + | for _, line := range wrapLine(v.Streaming, width, "") { | |
| 48 | + | out = append(out, line) | |
| 49 | + | } | |
| 50 | + | out = append(out, "") | |
| 51 | + | } | |
| 52 | + | for _, tc := range v.ToolCalls { | |
| 53 | + | out = append(out, v.renderToolCall(tc, width)...) | |
| 54 | + | out = append(out, "") | |
| 55 | + | } | |
| 56 | + | if v.Err != "" { | |
| 57 | + | out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err)) | |
| 58 | + | out = append(out, "") | |
| 59 | + | } | |
| 60 | + | return out | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (v *View) renderMessage(m provider.Message, width int) []string { | |
| 64 | + | var lines []string | |
| 65 | + | switch m.Role { | |
| 66 | + | case provider.RoleUser: | |
| 67 | + | header := v.Theme.FG256(v.Theme.User, "▍ you") | |
| 68 | + | lines = append(lines, header) | |
| 69 | + | for _, c := range m.Content { | |
| 70 | + | switch b := c.(type) { | |
| 71 | + | case provider.TextBlock: | |
| 72 | + | for _, l := range strings.Split(b.Text, "\n") { | |
| 73 | + | for _, w := range wrapLine(l, width-2, " ") { | |
| 74 | + | lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w)) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | case provider.ImageBlock: | |
| 78 | + | lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data)))) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | case provider.RoleAssistant: | |
| 82 | + | header := v.Theme.FG256(v.Theme.Assistant, "▍ zot") | |
| 83 | + | lines = append(lines, header) | |
| 84 | + | for _, c := range m.Content { | |
| 85 | + | switch b := c.(type) { | |
| 86 | + | case provider.TextBlock: | |
| 87 | + | md := RenderMarkdown(b.Text, v.Theme, width) | |
| 88 | + | for _, l := range strings.Split(md, "\n") { | |
| 89 | + | for _, w := range wrapLine(l, width, "") { | |
| 90 | + | lines = append(lines, w) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | case provider.ToolCallBlock: | |
| 94 | + | lines = append(lines, v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments))) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | case provider.RoleTool: | |
| 98 | + | for _, c := range m.Content { | |
| 99 | + | if tr, ok := c.(provider.ToolResultBlock); ok { | |
| 100 | + | title := " result" | |
| 101 | + | color := v.Theme.ToolOut | |
| 102 | + | if tr.IsError { | |
| 103 | + | title = " error" | |
| 104 | + | color = v.Theme.Error | |
| 105 | + | } | |
| 106 | + | lines = append(lines, v.Theme.FG256(color, title)) | |
| 107 | + | lines = append(lines, v.renderToolResultContent(tr.Content, width, color)...) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return lines | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| 115 | + | var lines []string | |
| 116 | + | head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+tc.Args) | |
| 117 | + | lines = append(lines, head) | |
| 118 | + | if tc.Result != "" { | |
| 119 | + | color := v.Theme.ToolOut | |
| 120 | + | if tc.Error { | |
| 121 | + | color = v.Theme.Error | |
| 122 | + | } | |
| 123 | + | lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...) | |
| 124 | + | } | |
| 125 | + | return lines | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // toolResultBlock wraps text in thin horizontal rules (top + bottom), | |
| 129 | + | // indenting the body with four spaces. The rules span the content column. | |
| 130 | + | // renderToolResultContent renders the body of a tool result block. | |
| 131 | + | // Text blocks get the usual rules-wrapped treatment; text that looks | |
| 132 | + | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 133 | + | // inline when the terminal supports a protocol, else as a text | |
| 134 | + | // placeholder with dimensions. | |
| 135 | + | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int) []string { | |
| 136 | + | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | |
| 137 | + | ||
| 138 | + | var out []string | |
| 139 | + | out = append(out, rule) | |
| 140 | + | for _, b := range blocks { | |
| 141 | + | switch bb := b.(type) { | |
| 142 | + | case provider.TextBlock: | |
| 143 | + | out = append(out, v.renderToolText(bb.Text, width, color)...) | |
| 144 | + | case provider.ImageBlock: | |
| 145 | + | out = append(out, v.renderImageBlock(bb, width)...) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | out = append(out, rule) | |
| 149 | + | return out | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // renderToolText renders a text block inside a tool result. If the | |
| 153 | + | // text contains a unified-diff section (lines starting with "--- " / | |
| 154 | + | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| 155 | + | // colors matching git diff conventions. | |
| 156 | + | func (v *View) renderToolText(text string, width, defaultColor int) []string { | |
| 157 | + | // No truncation — the full tool output is rendered into chat and | |
| 158 | + | // becomes part of the scrollback you can page back through. | |
| 159 | + | lines := strings.Split(text, "\n") | |
| 160 | + | ||
| 161 | + | inDiff := false | |
| 162 | + | var out []string | |
| 163 | + | for _, l := range lines { | |
| 164 | + | // Detect diff header: "--- name" followed somewhere by "+++ name". | |
| 165 | + | if strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ") { | |
| 166 | + | inDiff = true | |
| 167 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |
| 168 | + | continue | |
| 169 | + | } | |
| 170 | + | if inDiff && len(l) > 0 { | |
| 171 | + | switch l[0] { | |
| 172 | + | case '+': | |
| 173 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool)) | |
| 174 | + | continue | |
| 175 | + | case '-': | |
| 176 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error)) | |
| 177 | + | continue | |
| 178 | + | case ' ': | |
| 179 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted)) | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | // Regular line. | |
| 184 | + | for _, w := range wrapLine(l, width-4, " ") { | |
| 185 | + | out = append(out, " "+v.Theme.FG256(defaultColor, w)) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return out | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // renderDiffRow renders a single unified-diff line in fg color only. | |
| 192 | + | // The leading +/-/space stays visible so the user can tell at a glance | |
| 193 | + | // what changed; the rest of the line is colored the same. Long lines | |
| 194 | + | // are wrapped with a 4-cell indent preserved. | |
| 195 | + | func (v *View) renderDiffRow(line string, width, color int) string { | |
| 196 | + | body := line | |
| 197 | + | if len(body) > width-4 { | |
| 198 | + | body = body[:width-7] + "…" | |
| 199 | + | } | |
| 200 | + | return " " + v.Theme.FG256(color, body) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // renderImageBlock returns the lines for one image, inline if possible. | |
| 204 | + | // | |
| 205 | + | // Inline image escapes paint into multiple terminal rows but the zot | |
| 206 | + | // renderer treats each slice entry as a single row. To prevent chat | |
| 207 | + | // content from being drawn on top of the image, we pad with blank rows | |
| 208 | + | // so the image's real footprint is reflected in the frame height. | |
| 209 | + | func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |
| 210 | + | w, h := ImageDimensions(b.Data) | |
| 211 | + | kb := len(b.Data) / 1024 | |
| 212 | + | info := fmt.Sprintf(" image · %s · %dx%d · %d KB", b.MimeType, w, h, kb) | |
| 213 | + | ||
| 214 | + | if v.ImageProto != ImageProtocolNone { | |
| 215 | + | // Clamp rendered width so the image never overflows the chat | |
| 216 | + | // column. Subtract a 4-cell indent for the tool result block, | |
| 217 | + | // cap at 60 cells, floor at 10. | |
| 218 | + | cells := width - 4 | |
| 219 | + | if cells > 60 { | |
| 220 | + | cells = 60 | |
| 221 | + | } | |
| 222 | + | if cells < 10 { | |
| 223 | + | cells = 10 | |
| 224 | + | } | |
| 225 | + | const maxRows = 20 | |
| 226 | + | if seq := RenderInlineImageScaled(v.ImageProto, b.Data, b.MimeType, cells, maxRows); seq != "" { | |
| 227 | + | rows := RowsForInlineImage(b.Data, cells, maxRows) | |
| 228 | + | if rows < 1 { | |
| 229 | + | rows = 1 | |
| 230 | + | } | |
| 231 | + | out := make([]string, 0, rows+1) | |
| 232 | + | out = append(out, " "+seq) | |
| 233 | + | // Reserve blank rows matching the image's on-screen height. | |
| 234 | + | for i := 1; i < rows; i++ { | |
| 235 | + | out = append(out, "") | |
| 236 | + | } | |
| 237 | + | out = append(out, v.Theme.FG256(v.Theme.Muted, info)) | |
| 238 | + | return out | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | // Text fallback. | |
| 242 | + | return []string{v.Theme.FG256(v.Theme.Muted, info)} | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func toolResultBlock(th Theme, text string, width int, color int) []string { | |
| 246 | + | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 247 | + | ||
| 248 | + | var out []string | |
| 249 | + | out = append(out, rule) | |
| 250 | + | for _, l := range strings.Split(text, "\n") { | |
| 251 | + | for _, w := range wrapLine(l, width-4, " ") { | |
| 252 | + | out = append(out, " "+th.FG256(color, w)) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | out = append(out, rule) | |
| 256 | + | return out | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func shortArgs(raw json.RawMessage) string { | |
| 260 | + | var v any | |
| 261 | + | if err := json.Unmarshal(raw, &v); err != nil { | |
| 262 | + | return "" | |
| 263 | + | } | |
| 264 | + | switch x := v.(type) { | |
| 265 | + | case map[string]any: | |
| 266 | + | for _, k := range []string{"path", "file_path", "command"} { | |
| 267 | + | if s, ok := x[k].(string); ok { | |
| 268 | + | if len(s) > 60 { | |
| 269 | + | s = s[:57] + "..." | |
| 270 | + | } | |
| 271 | + | return s | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | b, _ := json.Marshal(v) | |
| 276 | + | s := string(b) | |
| 277 | + | if len(s) > 60 { | |
| 278 | + | s = s[:57] + "..." | |
| 279 | + | } | |
| 280 | + | return s | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func collectText(blocks []provider.Content) string { | |
| 284 | + | var sb strings.Builder | |
| 285 | + | for _, b := range blocks { | |
| 286 | + | if tb, ok := b.(provider.TextBlock); ok { | |
| 287 | + | if sb.Len() > 0 { | |
| 288 | + | sb.WriteString("\n") | |
| 289 | + | } | |
| 290 | + | sb.WriteString(tb.Text) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | return sb.String() | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func truncateLines(s string, n int) string { | |
| 297 | + | lines := strings.Split(s, "\n") | |
| 298 | + | if len(lines) <= n { | |
| 299 | + | return s | |
| 300 | + | } | |
| 301 | + | return strings.Join(lines[:n], "\n") + "\n … (" + fmt.Sprintf("%d", len(lines)-n) + " more)" | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // StatusBar builds the single-line status shown above the editor. | |
| 305 | + | // | |
| 306 | + | // Layout: | |
| 307 | + | // [busyPrefix] provider-model tokens-cost ctrl+c hint cwd | |
| 308 | + | // ↑ right-aligned | |
| 309 | + | // | |
| 310 | + | // cols is the terminal width; when > 0 the cwd is placed flush-right | |
| 311 | + | // with spaces. busyPrefix, if non-empty, is injected at the far left. | |
| 312 | + | func StatusBar(th Theme, model, prov string, cum provider.Usage, busy bool, busyPrefix, cwd string, locked bool, ctxUsed, ctxMax int, cols int) string { | |
| 313 | + | sep := " - " | |
| 314 | + | cost := fmt.Sprintf("$%.4f", cum.CostUSD) | |
| 315 | + | tokens := fmt.Sprintf("%d↑ %d↓", cum.InputTokens, cum.OutputTokens) | |
| 316 | + | left := fmt.Sprintf(" %s%s%s ", prov, sep, model) | |
| 317 | + | ctx := formatContextUsage(ctxUsed, ctxMax) | |
| 318 | + | middle := fmt.Sprintf(" %s%s%s%s%s ", tokens, sep, cost, sep, ctx) | |
| 319 | + | var hint string | |
| 320 | + | if busy { | |
| 321 | + | hint = " esc cancel " | |
| 322 | + | } else { | |
| 323 | + | hint = " ctrl+c exit " + sep + " /help " | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | var leftBuilder strings.Builder | |
| 327 | + | if busyPrefix != "" { | |
| 328 | + | leftBuilder.WriteString(th.FG256(th.Accent, " "+busyPrefix+" ")) | |
| 329 | + | leftBuilder.WriteString(" ") | |
| 330 | + | } | |
| 331 | + | leftBuilder.WriteString(th.FG256(th.Muted, left)) | |
| 332 | + | leftBuilder.WriteString(" ") | |
| 333 | + | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | |
| 334 | + | leftBuilder.WriteString(" ") | |
| 335 | + | leftBuilder.WriteString(th.FG256(th.Muted, hint)) | |
| 336 | + | ||
| 337 | + | // Compose cwd on the right side. shortenHome abbreviates $HOME to "~". | |
| 338 | + | right := shortenHome(cwd) | |
| 339 | + | if locked && right != "" { | |
| 340 | + | right = "· locked · " + right | |
| 341 | + | } | |
| 342 | + | if right == "" || cols <= 0 { | |
| 343 | + | return leftBuilder.String() | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | leftRendered := leftBuilder.String() | |
| 347 | + | leftWidth := visibleWidth(leftRendered) | |
| 348 | + | rightStr := " " + right + " " | |
| 349 | + | rightWidth := visibleWidth(rightStr) | |
| 350 | + | ||
| 351 | + | gap := cols - leftWidth - rightWidth | |
| 352 | + | if gap < 1 { | |
| 353 | + | // Not enough room for the cwd; drop it silently to avoid wrapping. | |
| 354 | + | return leftRendered | |
| 355 | + | } | |
| 356 | + | return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // formatContextUsage returns a short string describing the share of the | |
| 360 | + | // model's context window currently occupied by the latest turn's input. | |
| 361 | + | func formatContextUsage(used, max int) string { | |
| 362 | + | if max <= 0 { | |
| 363 | + | if used <= 0 { | |
| 364 | + | return "ctx –" | |
| 365 | + | } | |
| 366 | + | return fmt.Sprintf("ctx %s", shortTokens(used)) | |
| 367 | + | } | |
| 368 | + | pct := float64(used) / float64(max) * 100 | |
| 369 | + | return fmt.Sprintf("ctx %s/%s (%.0f%%)", shortTokens(used), shortTokens(max), pct) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func shortTokens(n int) string { | |
| 373 | + | switch { | |
| 374 | + | case n <= 0: | |
| 375 | + | return "0" | |
| 376 | + | case n < 1000: | |
| 377 | + | return fmt.Sprintf("%d", n) | |
| 378 | + | case n < 1_000_000: | |
| 379 | + | return fmt.Sprintf("%.1fk", float64(n)/1000) | |
| 380 | + | default: | |
| 381 | + | return fmt.Sprintf("%.1fM", float64(n)/1_000_000) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // shortenHome replaces the user's $HOME prefix with "~" for readability. | |
| 386 | + | func shortenHome(p string) string { | |
| 387 | + | if p == "" { | |
| 388 | + | return "" | |
| 389 | + | } | |
| 390 | + | home := osUserHome() | |
| 391 | + | if home == "" { | |
| 392 | + | return p | |
| 393 | + | } | |
| 394 | + | if p == home { | |
| 395 | + | return "~" | |
| 396 | + | } | |
| 397 | + | if strings.HasPrefix(p, home+"/") { | |
| 398 | + | return "~" + p[len(home):] | |
| 399 | + | } | |
| 400 | + | return p | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | // osUserHome is a tiny wrapper around os.UserHomeDir so tests can mock it. | |
| 404 | + | var osUserHome = func() string { | |
| 405 | + | if h, err := osUserHomeDir(); err == nil { | |
| 406 | + | return h | |
| 407 | + | } | |
| 408 | + | return "" | |
| 409 | + | } | |