detached 6cced27

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("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close