v0.3.60 bfc7b11

openai_codex.go RawHistoryFolder

1package provider
2
3import (
4	"bytes"
5	"context"
6	"crypto/rand"
7	"encoding/base64"
8	"encoding/hex"
9	"encoding/json"
10	"fmt"
11	"io"
12	"net/http"
13	"runtime"
14	"strings"
15	"time"
16)
17
18// Codex (ChatGPT subscription) client. Uses OpenAI's Responses API via
19// chatgpt.com/backend-api with the chatgpt-account-id handshake.
20//
21// Wire protocol notes:
22//   - Endpoint: POST https://chatgpt.com/backend-api/codex/responses
23//   - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,
24//     OpenAI-Beta: responses=experimental, originator: zot
25//   - Body: OpenAI Responses API shape (not chat/completions).
26//     input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]
27//     instructions: <system prompt>
28//     tools: [{type:"function", name, description, parameters, strict}]
29//     stream: true
30//   - SSE events: response.output_item.added, response.output_text.delta,
31//     response.function_call_arguments.delta, response.output_item.done,
32//     response.completed, response.failed, error
33//
34// The access_token comes from the OpenAI OAuth flow (auth.openai.com).
35// The accountID is parsed from the id_token JWT's `chatgpt_account_id`
36// claim; see auth/oauth.go.
37
38const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"
39
40type codexClient struct {
41	token             string
42	accountID         string
43	baseURL           string
44	errorLabel        string
45	providerName      string
46	modelName         func(string) string
47	disableCLIRouting bool
48	cliRoutingAll     bool
49	http              *http.Client
50}
51
52// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
53// using a subscription OAuth access token and the user's ChatGPT
54// account id. baseURL may be empty to use the default.
55func NewOpenAICodex(token, accountID, baseURL string) Client {
56	if baseURL == "" {
57		baseURL = codexDefaultBaseURL
58	}
59	return &codexClient{
60		token:        token,
61		accountID:    accountID,
62		baseURL:      strings.TrimRight(baseURL, "/"),
63		errorLabel:   "codex",
64		providerName: "openai-codex",
65		// The ChatGPT Codex backend load-sheds requests from client
66		// identities it does not recognize: unknown originator/user-agent
67		// pairs receive "Our servers are currently overloaded" stream
68		// errors near-deterministically while Codex CLI requests succeed.
69		// Send the Codex CLI request shape for every model on this
70		// backend.
71		cliRoutingAll: true,
72		http:          &http.Client{Timeout: 0},
73	}
74}
75
76func (c *codexClient) Name() string { return "openai-codex" }
77
78// ---- Responses API wire types (subset needed for zot's surface) ----
79
80type codexInputText struct {
81	Type string `json:"type"` // "input_text"
82	Text string `json:"text"`
83}
84
85type codexInputImage struct {
86	Type     string `json:"type"` // "input_image"
87	Detail   string `json:"detail"`
88	ImageURL string `json:"image_url"`
89}
90
91type codexOutputText struct {
92	Type        string `json:"type"` // "output_text"
93	Text        string `json:"text"`
94	Annotations []any  `json:"annotations"`
95}
96
97type codexInputMessage struct {
98	Type    string `json:"type,omitempty"` // "message" (optional for input)
99	Role    string `json:"role"`
100	Content []any  `json:"content"`
101}
102
103type codexOutputMessage struct {
104	Type    string            `json:"type"` // "message"
105	Role    string            `json:"role"`
106	Status  string            `json:"status,omitempty"`
107	ID      string            `json:"id,omitempty"`
108	Content []codexOutputText `json:"content"`
109}
110
111type codexFunctionCall struct {
112	Type      string `json:"type"` // "function_call"
113	ID        string `json:"id,omitempty"`
114	CallID    string `json:"call_id"`
115	Name      string `json:"name"`
116	Arguments string `json:"arguments"` // JSON string
117}
118
119type codexFunctionCallOutput struct {
120	Type   string `json:"type"` // "function_call_output"
121	CallID string `json:"call_id"`
122	Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
123}
124
125// codexReasoningItem mirrors the Responses API "reasoning" output item.
126// We capture it on incoming streams and replay it verbatim on follow-up
127// requests: the API rejects assistant tool-call replays without it when
128// thinking is enabled.
129type codexReasoningItem struct {
130	Type             string `json:"type"` // "reasoning"
131	ID               string `json:"id,omitempty"`
132	EncryptedContent string `json:"encrypted_content,omitempty"`
133	// Summary is required by the Responses API even when no summary text
134	// was streamed; encode an empty array rather than omitting the field.
135	Summary []codexReasoningSummary `json:"summary"`
136}
137
138type codexReasoningSummary struct {
139	Type string `json:"type"` // "summary_text"
140	Text string `json:"text"`
141}
142
143type codexTool struct {
144	Type        string          `json:"type"` // "function"
145	Name        string          `json:"name"`
146	Description string          `json:"description,omitempty"`
147	Parameters  json.RawMessage `json:"parameters"`
148}
149
150type codexReasoningConfig struct {
151	Effort string `json:"effort,omitempty"`
152}
153
154type codexRequest struct {
155	Model             string                `json:"model"`
156	Store             bool                  `json:"store"`
157	Stream            bool                  `json:"stream"`
158	Instructions      string                `json:"instructions,omitempty"`
159	Input             []any                 `json:"input"`
160	Tools             []codexTool           `json:"tools,omitempty"`
161	ToolChoice        string                `json:"tool_choice,omitempty"`
162	ParallelToolCalls bool                  `json:"parallel_tool_calls"`
163	Include           []string              `json:"include,omitempty"`
164	Reasoning         *codexReasoningConfig `json:"reasoning,omitempty"`
165	PromptCacheKey    string                `json:"prompt_cache_key,omitempty"`
166}
167
168// ---- Request building ----
169
170func (c *codexClient) findModel(id string) (Model, error) {
171	if c.providerName != "" && c.providerName != "openai-codex" {
172		if m, err := FindModel(c.providerName, id); err == nil {
173			return m, nil
174		}
175	}
176	if m, err := FindModel("openai-codex", id); err == nil {
177		return m, nil
178	}
179	return FindModel("openai", id)
180}
181
182func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
183	m, err := c.findModel(req.Model)
184	if err != nil {
185		return nil, err
186	}
187	reasoning := ClampReasoningForModel(m, req.Reasoning)
188
189	body := &codexRequest{
190		Model:             req.Model,
191		Store:             false,
192		Stream:            true,
193		Instructions:      req.System,
194		ParallelToolCalls: true,
195		Include:           []string{"reasoning.encrypted_content"},
196	}
197	if m.Reasoning {
198		effort := OpenAICodexReasoningEffort(reasoning, req.Model)
199		if hasReasoningLevelOverride(m, req.Reasoning) {
200			effort = reasoning
201		}
202		if effort != "" {
203			body.Reasoning = &codexReasoningConfig{Effort: effort}
204		}
205	}
206	activeTools := activeToolDefinitions(req.Tools, req.Messages)
207	if len(activeTools) > 0 {
208		body.ToolChoice = "auto"
209		for _, t := range activeTools {
210			params := t.Schema
211			if len(params) == 0 {
212				params = json.RawMessage(`{"type":"object","properties":{}}`)
213			}
214			body.Tools = append(body.Tools, codexTool{
215				Type:        "function",
216				Name:        t.Name,
217				Description: t.Description,
218				Parameters:  params,
219			})
220		}
221	}
222
223	msgIdx := 0
224	req.Messages = RepairOrphanedToolResults(req.Messages)
225	for _, msg := range req.Messages {
226		switch msg.Role {
227		case RoleUser:
228			content := []any{}
229			for _, c := range msg.Content {
230				switch v := c.(type) {
231				case TextBlock:
232					if v.Text != "" {
233						content = append(content, codexInputText{Type: "input_text", Text: v.Text})
234					}
235				case ImageBlock:
236					url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
237					content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
238				}
239			}
240			if len(content) == 0 {
241				continue
242			}
243			body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
244		case RoleAssistant:
245			// Emit one output_message per text block, one function_call per
246			// tool call, and one reasoning item per ReasoningBlock,
247			// preserving the order so the model sees the same interleaving
248			// we captured. The reasoning replay is what keeps OpenAI
249			// Codex from rejecting follow-up tool calls with
250			// "thinking is enabled but reasoning_content is missing".
251			for _, c := range msg.Content {
252				switch v := c.(type) {
253				case ReasoningBlock:
254					item := codexReasoningItem{
255						Type:             "reasoning",
256						ID:               v.ID,
257						EncryptedContent: v.Encrypted,
258						Summary:          []codexReasoningSummary{},
259					}
260					if v.Summary != "" {
261						item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
262					}
263					body.Input = append(body.Input, item)
264				case TextBlock:
265					if v.Text == "" {
266						continue
267					}
268					msgIdx++
269					body.Input = append(body.Input, codexOutputMessage{
270						Type:   "message",
271						Role:   "assistant",
272						Status: "completed",
273						ID:     fmt.Sprintf("msg_%d", msgIdx),
274						Content: []codexOutputText{
275							{Type: "output_text", Text: v.Text, Annotations: []any{}},
276						},
277					})
278				case ToolCallBlock:
279					args := string(v.Arguments)
280					if args == "" || !json.Valid([]byte(args)) {
281						args = "{}"
282					}
283					callID, _ := splitCallID(v.ID)
284					body.Input = append(body.Input, codexFunctionCall{
285						Type:      "function_call",
286						CallID:    callID,
287						Name:      v.Name,
288						Arguments: args,
289					})
290				}
291			}
292		case RoleTool:
293			for _, c := range msg.Content {
294				if tr, ok := c.(ToolResultBlock); ok {
295					var text strings.Builder
296					imageCount := 0
297					for _, inner := range tr.Content {
298						switch v := inner.(type) {
299						case TextBlock:
300							if text.Len() > 0 {
301								text.WriteString("\n")
302							}
303							text.WriteString(v.Text)
304						case ImageBlock:
305							imageCount++
306						}
307					}
308					// The Responses API function_call_output only carries a
309					// string, so image bytes cannot ride along here. The agent
310					// loop mirrors any tool-result images into the following
311					// user message (which this client does serialize as
312					// input_image). Leave a short text note so an image-only
313					// result is not an empty output the API may reject, and so
314					// the model knows the image arrives next.
315					out := text.String()
316					if out == "" && imageCount > 0 {
317						if imageCount == 1 {
318							out = "[image returned; see the following message]"
319						} else {
320							out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
321						}
322					}
323					callID, _ := splitCallID(tr.CallID)
324					body.Input = append(body.Input, codexFunctionCallOutput{
325						Type:   "function_call_output",
326						CallID: callID,
327						Output: out,
328					})
329				}
330			}
331		}
332	}
333
334	return body, nil
335}
336
337func splitCallID(id string) (string, string) {
338	if i := strings.Index(id, "|"); i >= 0 {
339		return id[:i], id[i+1:]
340	}
341	return id, ""
342}
343
344func usesCodexCLIRouting(model string) bool {
345	switch model {
346	case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
347		return true
348	default:
349		return false
350	}
351}
352
353func newCodexSessionID() string {
354	var b [16]byte
355	if _, err := rand.Read(b[:]); err != nil {
356		return fmt.Sprintf("zot-%d", time.Now().UnixNano())
357	}
358	return strings.Join([]string{
359		hex.EncodeToString(b[0:4]),
360		hex.EncodeToString(b[4:6]),
361		hex.EncodeToString(b[6:8]),
362		hex.EncodeToString(b[8:10]),
363		hex.EncodeToString(b[10:16]),
364	}, "-")
365}
366
367// ---- Streaming ----
368
369func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
370	wire, err := c.buildRequest(req)
371	if err != nil {
372		return nil, err
373	}
374	if c.modelName != nil {
375		wire.Model = c.modelName(wire.Model)
376	}
377	useCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
378	var codexSessionID string
379	if useCLIRouting {
380		codexSessionID = newCodexSessionID()
381		wire.PromptCacheKey = codexSessionID
382	}
383	body, err := json.Marshal(wire)
384	if err != nil {
385		return nil, err
386	}
387
388	newReq := func(cliRouting bool) func() (*http.Request, error) {
389		return func() (*http.Request, error) {
390			httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
391			if err != nil {
392				return nil, err
393			}
394			httpReq.Header.Set("content-type", "application/json")
395			httpReq.Header.Set("accept", "text/event-stream")
396			httpReq.Header.Set("authorization", "Bearer "+c.token)
397			httpReq.Header.Set("chatgpt-account-id", c.accountID)
398			httpReq.Header.Set("openai-beta", "responses=experimental")
399			if codexSessionID != "" {
400				httpReq.Header.Set("session-id", codexSessionID)
401				httpReq.Header.Set("x-client-request-id", codexSessionID)
402			}
403			if cliRouting {
404				// The ChatGPT Codex backend normally requires the Codex CLI
405				// request identity. A narrow fallback below uses zot's identity
406				// when that compatibility shape selects unsupported cache policy.
407				httpReq.Header.Set("originator", "codex_cli_rs")
408				httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
409			} else {
410				httpReq.Header.Set("originator", "zot")
411				httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
412			}
413			return httpReq, nil
414		}
415	}
416
417	resp, err := doStreamWithRetry(ctx, c.http, newReq(useCLIRouting))
418	if err != nil {
419		return nil, fmt.Errorf("openai-codex: %w", err)
420	}
421	if resp.StatusCode != http.StatusOK {
422		status := resp.StatusCode
423		responseBody, _ := io.ReadAll(resp.Body)
424		resp.Body.Close()
425		if !useCLIRouting || !unsupportedPromptCacheRetention(status, responseBody) {
426			return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
427		}
428		resp, err = doStreamWithRetry(ctx, c.http, newReq(false))
429		if err != nil {
430			return nil, fmt.Errorf("openai-codex: cache compatibility retry: %w", err)
431		}
432		if resp.StatusCode != http.StatusOK {
433			status = resp.StatusCode
434			responseBody, _ = io.ReadAll(resp.Body)
435			resp.Body.Close()
436			return nil, fmt.Errorf("openai-codex: http %d: %s", status, strings.TrimSpace(string(responseBody)))
437		}
438	}
439
440	out := make(chan Event, 16)
441	go c.runStream(ctx, resp, req, out)
442	return out, nil
443}
444
445func unsupportedPromptCacheRetention(status int, body []byte) bool {
446	if status != http.StatusBadRequest {
447		return false
448	}
449	var payload struct {
450		Error struct {
451			Message string `json:"message"`
452			Param   string `json:"param"`
453			Code    string `json:"code"`
454		} `json:"error"`
455	}
456	if json.Unmarshal(body, &payload) != nil || payload.Error.Param != "prompt_cache_retention" {
457		return false
458	}
459	message := strings.ToLower(payload.Error.Message)
460	return payload.Error.Code == "invalid_parameter" || strings.Contains(message, "not supported")
461}
462
463func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
464	defer close(out)
465	defer resp.Body.Close()
466
467	model, _ := c.findModel(req.Model)
468	providerName := c.providerName
469	if providerName == "" {
470		providerName = "openai-codex"
471	}
472	out <- EventStart{Model: req.Model, Provider: providerName}
473
474	raw := make(chan sseEvent, 16)
475	go readSSE(resp.Body, raw)
476
477	// Accumulators. The Responses API emits output_items in order; each
478	// item is either a "message" (text) or a "function_call". We track
479	// the in-flight item by its index.
480	type itemState struct {
481		kind      string // "message" | "function_call" | "reasoning"
482		callID    string
483		name      string
484		argsBuf   strings.Builder
485		textBuf   strings.Builder
486		summary   strings.Builder
487		rawID     string
488		encrypted string
489		announced bool
490	}
491	var (
492		items    = map[int]*itemState{}
493		order    []int
494		usage    Usage
495		stop     StopReason = StopEnd
496		finalErr error
497	)
498
499	assemble := func() Message {
500		content := []Content{}
501		for _, idx := range order {
502			it := items[idx]
503			switch it.kind {
504			case "message":
505				if it.textBuf.Len() > 0 {
506					content = append(content, TextBlock{Text: it.textBuf.String()})
507				}
508			case "function_call":
509				args := it.argsBuf.String()
510				if args == "" || !json.Valid([]byte(args)) {
511					args = "{}"
512				}
513				content = append(content, ToolCallBlock{
514					ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
515				})
516			case "reasoning":
517				if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
518					continue
519				}
520				content = append(content, ReasoningBlock{
521					ID:        it.rawID,
522					Summary:   it.summary.String(),
523					Encrypted: it.encrypted,
524				})
525			}
526		}
527		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
528	}
529
530	sendDone := func() {
531		usage.CostUSD = ComputeCost(model, usage)
532		out <- EventUsage{Usage: usage}
533		out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
534	}
535
536	for {
537		select {
538		case <-ctx.Done():
539			stop = StopAborted
540			finalErr = ctx.Err()
541			sendDone()
542			return
543		case ev, ok := <-raw:
544			if !ok {
545				sendDone()
546				return
547			}
548			var head struct {
549				Type string `json:"type"`
550			}
551			if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
552				continue
553			}
554
555			switch head.Type {
556			case "response.output_item.added":
557				var p struct {
558					OutputIndex int `json:"output_index"`
559					Item        struct {
560						Type             string `json:"type"` // "message" | "function_call" | "reasoning"
561						ID               string `json:"id"`
562						CallID           string `json:"call_id"`
563						Name             string `json:"name"`
564						EncryptedContent string `json:"encrypted_content"`
565					} `json:"item"`
566				}
567				_ = json.Unmarshal([]byte(ev.Data), &p)
568				it := &itemState{}
569				switch p.Item.Type {
570				case "message":
571					it.kind = "message"
572				case "function_call":
573					it.kind = "function_call"
574					it.callID = p.Item.CallID
575					it.name = p.Item.Name
576					if !it.announced {
577						it.announced = true
578						out <- EventToolStart{ID: it.callID, Name: it.name}
579					}
580				case "reasoning":
581					it.kind = "reasoning"
582					it.rawID = p.Item.ID
583					it.encrypted = p.Item.EncryptedContent
584				default:
585					continue
586				}
587				items[p.OutputIndex] = it
588				order = append(order, p.OutputIndex)
589			case "response.output_text.delta":
590				var p struct {
591					OutputIndex int    `json:"output_index"`
592					Delta       string `json:"delta"`
593				}
594				_ = json.Unmarshal([]byte(ev.Data), &p)
595				if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
596					it.textBuf.WriteString(p.Delta)
597					out <- EventTextDelta{Delta: p.Delta}
598				}
599			case "response.reasoning_summary_text.delta":
600				var p struct {
601					OutputIndex int    `json:"output_index"`
602					Delta       string `json:"delta"`
603				}
604				_ = json.Unmarshal([]byte(ev.Data), &p)
605				if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
606					it.summary.WriteString(p.Delta)
607				}
608			case "response.reasoning_summary_text.done":
609				// summary text already accumulated via deltas
610			case "response.function_call_arguments.delta":
611				var p struct {
612					OutputIndex int    `json:"output_index"`
613					Delta       string `json:"delta"`
614				}
615				_ = json.Unmarshal([]byte(ev.Data), &p)
616				if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
617					it.argsBuf.WriteString(p.Delta)
618					out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
619				}
620			case "response.output_item.done":
621				var p struct {
622					OutputIndex int `json:"output_index"`
623					Item        struct {
624						Type             string `json:"type"`
625						ID               string `json:"id"`
626						EncryptedContent string `json:"encrypted_content"`
627						Summary          []struct {
628							Type string `json:"type"`
629							Text string `json:"text"`
630						} `json:"summary"`
631					} `json:"item"`
632				}
633				_ = json.Unmarshal([]byte(ev.Data), &p)
634				if it, ok := items[p.OutputIndex]; ok {
635					switch it.kind {
636					case "function_call":
637						out <- EventToolEnd{ID: it.callID}
638					case "reasoning":
639						if p.Item.EncryptedContent != "" {
640							it.encrypted = p.Item.EncryptedContent
641						}
642						if it.rawID == "" && p.Item.ID != "" {
643							it.rawID = p.Item.ID
644						}
645						for _, s := range p.Item.Summary {
646							if s.Text == "" {
647								continue
648							}
649							if it.summary.Len() > 0 {
650								it.summary.WriteString("\n")
651							}
652							it.summary.WriteString(s.Text)
653						}
654					}
655				}
656			case "response.completed", "response.done":
657				var p struct {
658					Response struct {
659						Usage struct {
660							InputTokens        int `json:"input_tokens"`
661							OutputTokens       int `json:"output_tokens"`
662							InputTokensDetails struct {
663								CachedTokens int `json:"cached_tokens"`
664							} `json:"input_tokens_details"`
665							OutputTokensDetails *struct {
666								ReasoningTokens int `json:"reasoning_tokens"`
667							} `json:"output_tokens_details"`
668						} `json:"usage"`
669						Status string `json:"status"`
670					} `json:"response"`
671				}
672				_ = json.Unmarshal([]byte(ev.Data), &p)
673				usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
674				if usage.InputTokens < 0 {
675					usage.InputTokens = p.Response.Usage.InputTokens
676				}
677				usage.OutputTokens = p.Response.Usage.OutputTokens
678				usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
679				if details := p.Response.Usage.OutputTokensDetails; details != nil {
680					usage.ReasoningTokens = details.ReasoningTokens
681					usage.ReasoningTokensKnown = true
682				}
683
684				hadTool := false
685				for _, it := range items {
686					if it.kind == "function_call" {
687						hadTool = true
688						break
689					}
690				}
691				if hadTool {
692					stop = StopToolUse
693				} else {
694					stop = StopEnd
695				}
696				sendDone()
697				return
698			case "response.failed":
699				var p struct {
700					Response struct {
701						Error struct {
702							Message string `json:"message"`
703						} `json:"error"`
704					} `json:"response"`
705				}
706				_ = json.Unmarshal([]byte(ev.Data), &p)
707				stop = StopError
708				finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
709				sendDone()
710				return
711			case "error":
712				var p struct {
713					Message string `json:"message"`
714					Code    string `json:"code"`
715					Error   struct {
716						Message string `json:"message"`
717						Code    string `json:"code"`
718					} `json:"error"`
719				}
720				_ = json.Unmarshal([]byte(ev.Data), &p)
721				msg := p.Message
722				if msg == "" {
723					msg = p.Error.Message
724				}
725				if msg == "" {
726					msg = p.Code
727				}
728				if msg == "" {
729					msg = p.Error.Code
730				}
731				if msg == "" {
732					msg = strings.TrimSpace(ev.Data)
733				}
734				stop = StopError
735				label := c.errorLabel
736				if label == "" {
737					label = "codex"
738				}
739				finalErr = fmt.Errorf("%s error: %s", label, msg)
740				sendDone()
741				return
742			}
743		}
744	}
745}

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