v0.1.77 43da5e5

anthropic.go RawHistoryFolder

1package provider
2
3import (
4	"bytes"
5	"context"
6	"encoding/base64"
7	"encoding/json"
8	"fmt"
9	"io"
10	"net/http"
11	"os"
12	"strings"
13	"time"
14)
15
16const anthropicDefaultBaseURL = "https://api.anthropic.com"
17const anthropicAPIVersion = "2023-06-01"
18
19// Stealth identity used when talking to Anthropic via subscription OAuth.
20// These values mimic the official Claude Code CLI so Anthropic's edge
21// accepts the request; diverging from them causes 429 rate_limit_error
22// or 403 on the very first request.
23const (
24	claudeCodeVersion  = "2.1.75"
25	claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
26)
27
28// Claude Code's canonical tool casing. When running under OAuth we must
29// advertise tool names that match this list (case-insensitive lookup),
30// because Anthropic's backend cross-checks them.
31var claudeCodeToolNames = map[string]string{
32	"read":  "Read",
33	"write": "Write",
34	"edit":  "Edit",
35	"bash":  "Bash",
36	"grep":  "Grep",
37	"glob":  "Glob",
38}
39
40func toClaudeCodeToolName(name string) string {
41	if cc, ok := claudeCodeToolNames[strings.ToLower(name)]; ok {
42		return cc
43	}
44	return name
45}
46
47func fromClaudeCodeToolName(name string, tools []Tool) string {
48	lower := strings.ToLower(name)
49	for _, t := range tools {
50		if strings.ToLower(t.Name) == lower {
51			return t.Name
52		}
53	}
54	return name
55}
56
57// anthropicClient implements Client against the Anthropic Messages API.
58type anthropicClient struct {
59	apiKey   string
60	baseURL  string
61	oauthTok string // when non-empty, send Bearer auth instead of x-api-key
62	http     *http.Client
63}
64
65// NewAnthropic creates an Anthropic client using an API key. baseURL may be empty.
66func NewAnthropic(apiKey, baseURL string) Client {
67	if baseURL == "" {
68		baseURL = anthropicDefaultBaseURL
69	}
70	return &anthropicClient{
71		apiKey:  apiKey,
72		baseURL: strings.TrimRight(baseURL, "/"),
73		http:    &http.Client{Timeout: 0},
74	}
75}
76
77// NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token.
78func NewAnthropicOAuth(accessToken, baseURL string) Client {
79	if baseURL == "" {
80		baseURL = anthropicDefaultBaseURL
81	}
82	return &anthropicClient{
83		oauthTok: accessToken,
84		baseURL:  strings.TrimRight(baseURL, "/"),
85		http:     &http.Client{Timeout: 0},
86	}
87}
88
89func (c *anthropicClient) Name() string { return "anthropic" }
90
91// ---- wire types ----
92
93type anthTextBlock struct {
94	Type         string         `json:"type"` // "text"
95	Text         string         `json:"text"`
96	CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
97}
98
99type anthImageSource struct {
100	Type      string `json:"type"` // "base64"
101	MediaType string `json:"media_type"`
102	Data      string `json:"data"`
103}
104
105type anthImageBlock struct {
106	Type         string          `json:"type"` // "image"
107	Source       anthImageSource `json:"source"`
108	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
109}
110
111type anthToolUseBlock struct {
112	Type         string          `json:"type"` // "tool_use"
113	ID           string          `json:"id"`
114	Name         string          `json:"name"`
115	Input        json.RawMessage `json:"input"`
116	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
117}
118
119type anthToolResultBlock struct {
120	Type         string          `json:"type"` // "tool_result"
121	ToolUseID    string          `json:"tool_use_id"`
122	Content      json.RawMessage `json:"content"` // string or array of blocks
123	IsError      bool            `json:"is_error,omitempty"`
124	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
125}
126
127type anthCacheCtrl struct {
128	Type string `json:"type"` // "ephemeral"
129	TTL  string `json:"ttl,omitempty"`
130}
131
132type anthMessage struct {
133	Role    string        `json:"role"`
134	Content []interface{} `json:"content"`
135}
136
137type anthSystemBlock struct {
138	Type         string         `json:"type"` // "text"
139	Text         string         `json:"text"`
140	CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
141}
142
143type anthTool struct {
144	Name         string          `json:"name"`
145	Description  string          `json:"description,omitempty"`
146	InputSchema  json.RawMessage `json:"input_schema"`
147	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
148}
149
150type anthThinking struct {
151	Type         string `json:"type"` // "enabled"
152	BudgetTokens int    `json:"budget_tokens"`
153}
154
155type anthRequest struct {
156	Model       string            `json:"model"`
157	MaxTokens   int               `json:"max_tokens"`
158	System      []anthSystemBlock `json:"system,omitempty"`
159	Messages    []anthMessage     `json:"messages"`
160	Tools       []anthTool        `json:"tools,omitempty"`
161	Temperature *float32          `json:"temperature,omitempty"`
162	Thinking    *anthThinking     `json:"thinking,omitempty"`
163	Stream      bool              `json:"stream"`
164}
165
166// ---- request building ----
167
168func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
169	m, err := FindModel("anthropic", req.Model)
170	if err != nil {
171		return nil, err
172	}
173	maxTok := req.MaxTokens
174	if maxTok <= 0 {
175		maxTok = m.MaxOutput
176	}
177
178	out := &anthRequest{
179		Model:       req.Model,
180		MaxTokens:   maxTok,
181		Temperature: req.Temperature,
182		Stream:      true,
183	}
184
185	// System prompt assembly differs between api-key and OAuth modes.
186	// OAuth requests MUST begin with the Claude Code identity line or
187	// Anthropic rejects them (429 rate_limit_error with zero tokens used).
188	//
189	// Cache budget: anthropic caps cache_control to 4 breakpoints per
190	// request. We spend them on:
191	//   1. claude-code identity (OAuth only; stable forever)
192	//   2. user system prompt   (changes per-session at most)
193	//   3. last tool definition (tools change rarely)
194	//   4. last message block   (advances every turn)
195	//
196	// The identity line gets its OWN cache_control so the prefix
197	// [identity] is cacheable independently of the user system
198	// prompt. Without that, the cache prefix starts after block 2
199	// and any drift in the user prompt (e.g. the Current date
200	// line flipping at midnight) invalidates everything, including
201	// the 17 identity tokens we have to re-send every request
202	// forever.
203	if c.oauthTok != "" {
204		out.System = []anthSystemBlock{{
205			Type:         "text",
206			Text:         claudeCodeIdentity,
207			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
208		}}
209		if req.System != "" {
210			out.System = append(out.System, anthSystemBlock{
211				Type:         "text",
212				Text:         req.System,
213				CacheControl: &anthCacheCtrl{Type: "ephemeral"},
214			})
215		}
216	} else if req.System != "" {
217		out.System = []anthSystemBlock{{
218			Type:         "text",
219			Text:         req.System,
220			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
221		}}
222	}
223
224	if req.Reasoning != "" && m.Reasoning {
225		budget := anthropicReasoningBudget(req.Reasoning)
226		if budget > 0 {
227			out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
228			// Reasoning requires max_tokens > budget.
229			if out.MaxTokens <= budget {
230				out.MaxTokens = budget + 4096
231			}
232		}
233	}
234
235	for _, t := range req.Tools {
236		name := t.Name
237		if c.oauthTok != "" {
238			name = toClaudeCodeToolName(name)
239		}
240		out.Tools = append(out.Tools, anthTool{
241			Name:        name,
242			Description: t.Description,
243			InputSchema: t.Schema,
244		})
245	}
246	// Cache the last tool definition (applies cache breakpoint to the whole tools array).
247	if n := len(out.Tools); n > 0 {
248		out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"}
249	}
250
251	// Convert messages. Anthropic's wire format has only "user" and
252	// "assistant" roles; tool_result blocks live inside user messages.
253	//
254	// CRITICAL: tool_result blocks go into their OWN new user
255	// message, they are NOT merged into the preceding user message.
256	// Merging would mutate the prior user message's content array
257	// between turn N and turn N+1: turn N caches the prefix ending at
258	// [user: "read sample.ts"], turn N+1 sends
259	// [user: "read sample.ts" + tool_result=...] which is a
260	// different block sequence, busting the cache prefix match.
261	// Anthropic's API happily accepts consecutive user messages, and
262	// emitting them separately keeps each message bit-stable across
263	// turns, so the cache prefix matches for the entire history up
264	// to the newest block.
265	req.Messages = RepairOrphanedToolResults(req.Messages)
266	for _, msg := range req.Messages {
267		renameTools := c.oauthTok != ""
268		switch msg.Role {
269		case RoleUser:
270			out.Messages = append(out.Messages, anthMessage{
271				Role:    "user",
272				Content: convertAnthContent(msg.Content, renameTools),
273			})
274		case RoleTool:
275			out.Messages = append(out.Messages, anthMessage{
276				Role:    "user",
277				Content: convertAnthContent(msg.Content, renameTools),
278			})
279		case RoleAssistant:
280			out.Messages = append(out.Messages, anthMessage{
281				Role:    "assistant",
282				Content: convertAnthContent(msg.Content, renameTools),
283			})
284		}
285	}
286
287	// Tag the LAST user message with cache_control. Spends the 4th
288	// breakpoint. For prefixes under ~1024 tokens (Anthropic's
289	// minimum cacheable block size for Opus), no cache is written.
290	tagLastUserCache(out.Messages)
291
292	return out, nil
293}
294
295// tagLastUserCache marks the last block of the most recent user
296// message. One marker; combined with identity + systemPrompt +
297// last-tool, spends Anthropic's 4-breakpoint budget.
298func tagLastUserCache(msgs []anthMessage) {
299	for i := len(msgs) - 1; i >= 0; i-- {
300		if msgs[i].Role == "user" {
301			markLastBlockEphemeral(msgs[i].Content)
302			return
303		}
304	}
305}
306
307// markLastBlockEphemeral sets CacheControl on the last entry in blocks
308// regardless of whether it's a text, image, tool_use, or tool_result.
309// Each block type carries its own CacheControl pointer so we type-
310// switch + reassign the slice element.
311func markLastBlockEphemeral(blocks []interface{}) {
312	if len(blocks) == 0 {
313		return
314	}
315	i := len(blocks) - 1
316	cc := &anthCacheCtrl{Type: "ephemeral"}
317	switch v := blocks[i].(type) {
318	case anthTextBlock:
319		v.CacheControl = cc
320		blocks[i] = v
321	case anthImageBlock:
322		v.CacheControl = cc
323		blocks[i] = v
324	case anthToolUseBlock:
325		v.CacheControl = cc
326		blocks[i] = v
327	case anthToolResultBlock:
328		v.CacheControl = cc
329		blocks[i] = v
330	}
331}
332
333func anthropicReasoningBudget(level string) int {
334	switch strings.ToLower(level) {
335	case "low":
336		return 2048
337	case "medium":
338		return 8192
339	case "high":
340		return 16384
341	default:
342		return 0
343	}
344}
345
346func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
347	out := make([]interface{}, 0, len(blocks))
348	for _, b := range blocks {
349		switch v := b.(type) {
350		case TextBlock:
351			if v.Text == "" {
352				continue
353			}
354			out = append(out, anthTextBlock{Type: "text", Text: v.Text})
355		case ImageBlock:
356			data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
357			out = append(out, anthImageBlock{
358				Type: "image",
359				Source: anthImageSource{
360					Type:      "base64",
361					MediaType: mime,
362					Data:      base64.StdEncoding.EncodeToString(data),
363				},
364			})
365		case ToolCallBlock:
366			args := v.Arguments
367			if len(args) == 0 || !json.Valid(args) {
368				args = json.RawMessage("{}")
369			}
370			name := v.Name
371			if renameTools {
372				name = toClaudeCodeToolName(name)
373			}
374			out = append(out, anthToolUseBlock{
375				Type: "tool_use", ID: v.ID, Name: name, Input: args,
376			})
377		case ToolResultBlock:
378			// Flatten content to a string if all text; else array of blocks.
379			content, _ := anthBuildToolResultContent(v.Content)
380			out = append(out, anthToolResultBlock{
381				Type:      "tool_result",
382				ToolUseID: v.CallID,
383				Content:   content,
384				IsError:   v.IsError,
385			})
386		}
387	}
388	return out
389}
390
391func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
392	onlyText := true
393	var sb strings.Builder
394	for _, b := range blocks {
395		if tb, ok := b.(TextBlock); ok {
396			if sb.Len() > 0 {
397				sb.WriteString("\n")
398			}
399			sb.WriteString(tb.Text)
400		} else {
401			onlyText = false
402			break
403		}
404	}
405	if onlyText {
406		if sb.Len() == 0 {
407			return json.Marshal("")
408		}
409		return json.Marshal(sb.String())
410	}
411	// Array form: text + image blocks.
412	arr := make([]interface{}, 0, len(blocks))
413	for _, b := range blocks {
414		switch v := b.(type) {
415		case TextBlock:
416			arr = append(arr, anthTextBlock{Type: "text", Text: v.Text})
417		case ImageBlock:
418			data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType)
419			arr = append(arr, anthImageBlock{
420				Type: "image",
421				Source: anthImageSource{
422					Type:      "base64",
423					MediaType: mime,
424					Data:      base64.StdEncoding.EncodeToString(data),
425				},
426			})
427		}
428	}
429	return json.Marshal(arr)
430}
431
432// ---- streaming ----
433
434func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
435	wire, err := c.buildRequest(req)
436	if err != nil {
437		return nil, err
438	}
439	body, err := json.Marshal(wire)
440	if err != nil {
441		return nil, err
442	}
443
444	// Optional debug dump: when $ZOT_DEBUG_ANTHROPIC is a file path
445	// we append every outgoing request body to it, one JSON object
446	// per line. Useful for diffing turn N vs turn N+1 to understand
447	// why the cache prefix isn't matching.
448	if dump := os.Getenv("ZOT_DEBUG_ANTHROPIC"); dump != "" {
449		if f, derr := os.OpenFile(dump, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); derr == nil {
450			_, _ = f.Write(body)
451			_, _ = f.Write([]byte{'\n'})
452			_ = f.Close()
453		}
454	}
455
456	newReq := func() (*http.Request, error) {
457		httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
458		if err != nil {
459			return nil, err
460		}
461		httpReq.Header.Set("content-type", "application/json")
462		httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
463		if c.oauthTok != "" {
464			// Claude-Code-shaped request: identical headers and values as the
465			// official CLI. Any drift triggers Anthropic's anti-abuse check and
466			// rate-limits (or outright blocks) the request.
467			httpReq.Header.Set("accept", "application/json")
468			httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
469			httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
470			httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
471			httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion)
472			httpReq.Header.Set("x-app", "cli")
473			// Remove x-api-key entirely by NOT setting it.
474		} else {
475			httpReq.Header.Set("accept", "text/event-stream")
476			httpReq.Header.Set("x-api-key", c.apiKey)
477		}
478		return httpReq, nil
479	}
480
481	resp, err := doStreamWithRetry(ctx, c.http, newReq)
482	if err != nil {
483		return nil, fmt.Errorf("anthropic: %w", err)
484	}
485	if resp.StatusCode != http.StatusOK {
486		b, _ := io.ReadAll(resp.Body)
487		resp.Body.Close()
488		return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
489	}
490
491	out := make(chan Event, 16)
492	go c.runStream(ctx, resp, req, out)
493	return out, nil
494}
495
496func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
497	defer close(out)
498	defer resp.Body.Close()
499
500	model, _ := FindModel("anthropic", req.Model)
501	out <- EventStart{Model: req.Model, Provider: "anthropic"}
502
503	raw := make(chan sseEvent, 16)
504	go readSSE(resp.Body, raw)
505
506	// State for assembling the assistant message. Blocks are indexed
507	// by their `index` field from Anthropic so we can preserve the
508	// interleaved order the model emitted them in (text may come
509	// before OR after tool_use; mixing both happens frequently).
510	type blockEntry struct {
511		kind     string // "text" | "tool_use"
512		textBuf  strings.Builder
513		toolCall ToolCallBlock
514		toolArgs strings.Builder
515	}
516	var (
517		blocks     = map[int]*blockEntry{}
518		blockOrder []int // insertion order of indexes
519		activeIdx  = -1
520		usage      Usage
521		stop       StopReason = StopEnd
522		finalErr   error
523	)
524	_ = activeIdx // read-only indicator used for legacy parity
525
526	registerBlock := func(idx int, kind string) *blockEntry {
527		if be, ok := blocks[idx]; ok {
528			return be
529		}
530		be := &blockEntry{kind: kind}
531		blocks[idx] = be
532		blockOrder = append(blockOrder, idx)
533		return be
534	}
535
536	assembleMsg := func() Message {
537		content := []Content{}
538		for _, idx := range blockOrder {
539			be := blocks[idx]
540			switch be.kind {
541			case "text":
542				if be.textBuf.Len() > 0 {
543					content = append(content, TextBlock{Text: be.textBuf.String()})
544				}
545			case "tool_use":
546				tc := be.toolCall
547				args := be.toolArgs.String()
548				if args == "" {
549					args = "{}"
550				}
551				tc.Arguments = json.RawMessage(args)
552				content = append(content, tc)
553			}
554		}
555		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
556	}
557
558	sendDone := func() {
559		usage.CostUSD = ComputeCost(model, usage)
560		out <- EventUsage{Usage: usage}
561		out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
562	}
563
564	for {
565		select {
566		case <-ctx.Done():
567			stop = StopAborted
568			finalErr = ctx.Err()
569			sendDone()
570			return
571		case ev, ok := <-raw:
572			if !ok {
573				sendDone()
574				return
575			}
576			// Parse event payload based on event: type.
577			var payload map[string]json.RawMessage
578			if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
579				continue
580			}
581			switch ev.Event {
582			case "content_block_start":
583				var idx int
584				if b, ok := payload["index"]; ok {
585					_ = json.Unmarshal(b, &idx)
586				}
587				var block struct {
588					Type  string          `json:"type"`
589					ID    string          `json:"id"`
590					Name  string          `json:"name"`
591					Text  string          `json:"text"`
592					Input json.RawMessage `json:"input"`
593				}
594				if b, ok := payload["content_block"]; ok {
595					_ = json.Unmarshal(b, &block)
596				}
597				activeIdx = idx
598				switch block.Type {
599				case "tool_use":
600					name := block.Name
601					if c.oauthTok != "" {
602						name = fromClaudeCodeToolName(name, req.Tools)
603					}
604					be := registerBlock(idx, "tool_use")
605					be.toolCall = ToolCallBlock{ID: block.ID, Name: name}
606					out <- EventToolStart{ID: block.ID, Name: name}
607				case "text":
608					registerBlock(idx, "text")
609				case "thinking":
610					// not surfaced
611				}
612			case "content_block_delta":
613				var idx int
614				if b, ok := payload["index"]; ok {
615					_ = json.Unmarshal(b, &idx)
616				}
617				var d struct {
618					Type        string `json:"type"`
619					Text        string `json:"text"`
620					PartialJSON string `json:"partial_json"`
621					Thinking    string `json:"thinking"`
622				}
623				if b, ok := payload["delta"]; ok {
624					_ = json.Unmarshal(b, &d)
625				}
626				switch d.Type {
627				case "text_delta":
628					if be, ok := blocks[idx]; ok && be.kind == "text" {
629						be.textBuf.WriteString(d.Text)
630					}
631					out <- EventTextDelta{Delta: d.Text}
632				case "input_json_delta":
633					if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
634						be.toolArgs.WriteString(d.PartialJSON)
635						out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON}
636					}
637				case "thinking_delta":
638					// Not surfaced in v1.
639				}
640			case "content_block_stop":
641				var idx int
642				if b, ok := payload["index"]; ok {
643					_ = json.Unmarshal(b, &idx)
644				}
645				if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
646					out <- EventToolEnd{ID: be.toolCall.ID}
647				}
648				activeIdx = -1
649			case "message_start":
650				var m struct {
651					Message struct {
652						Usage struct {
653							InputTokens              int `json:"input_tokens"`
654							OutputTokens             int `json:"output_tokens"`
655							CacheReadInputTokens     int `json:"cache_read_input_tokens"`
656							CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
657						} `json:"usage"`
658					} `json:"message"`
659				}
660				_ = json.Unmarshal([]byte(ev.Data), &m)
661				// Anthropic sends cumulative values on message_start and
662				// again on message_delta (refreshed), so assign, don't
663				// accumulate. Accumulating doubles cache_creation_input
664				// which can be 50-70% of cost.
665				usage.InputTokens = m.Message.Usage.InputTokens
666				usage.OutputTokens = m.Message.Usage.OutputTokens
667				usage.CacheReadTokens = m.Message.Usage.CacheReadInputTokens
668				usage.CacheWriteTokens = m.Message.Usage.CacheCreationInputTokens
669			case "message_delta":
670				var m struct {
671					Delta struct {
672						StopReason string `json:"stop_reason"`
673					} `json:"delta"`
674					Usage struct {
675						InputTokens              int `json:"input_tokens"`
676						OutputTokens             int `json:"output_tokens"`
677						CacheReadInputTokens     int `json:"cache_read_input_tokens"`
678						CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
679					} `json:"usage"`
680				}
681				_ = json.Unmarshal([]byte(ev.Data), &m)
682				// Refresh usage from the latest cumulative totals
683				// Anthropic provides. Only apply non-zero fields in case
684				// a given delta only carries output tokens.
685				if m.Usage.InputTokens > 0 {
686					usage.InputTokens = m.Usage.InputTokens
687				}
688				if m.Usage.OutputTokens > 0 {
689					usage.OutputTokens = m.Usage.OutputTokens
690				}
691				if m.Usage.CacheReadInputTokens > 0 {
692					usage.CacheReadTokens = m.Usage.CacheReadInputTokens
693				}
694				if m.Usage.CacheCreationInputTokens > 0 {
695					usage.CacheWriteTokens = m.Usage.CacheCreationInputTokens
696				}
697				switch m.Delta.StopReason {
698				case "end_turn", "stop_sequence":
699					stop = StopEnd
700				case "max_tokens":
701					stop = StopLength
702				case "tool_use":
703					stop = StopToolUse
704				}
705			case "message_stop":
706				sendDone()
707				return
708			case "error":
709				var e struct {
710					Error struct {
711						Type    string `json:"type"`
712						Message string `json:"message"`
713					} `json:"error"`
714				}
715				_ = json.Unmarshal([]byte(ev.Data), &e)
716				stop = StopError
717				finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message)
718				sendDone()
719				return
720			}
721			_ = activeIdx
722		}
723	}
724}

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