v0.24.0 3dbf466

openai_responses_websocket_test.go RawHistoryFolder

1package provider
2
3import (
4	"context"
5	"encoding/json"
6	"io"
7	"net/http"
8	"net/http/httptest"
9	"reflect"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14
15	"golang.org/x/net/websocket"
16)
17
18func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *testing.T) {
19	var (
20		mu       sync.Mutex
21		requests []map[string]any
22		visits   int
23	)
24	ws := websocket.Handler(func(conn *websocket.Conn) {
25		defer conn.Close()
26		mu.Lock()
27		visits++
28		visit := visits
29		mu.Unlock()
30		if visit == 1 {
31			var first map[string]any
32			if err := websocket.JSON.Receive(conn, &first); err != nil {
33				return
34			}
35			mu.Lock()
36			requests = append(requests, first)
37			mu.Unlock()
38			for _, event := range []map[string]any{
39				{"type": "response.created", "response": map[string]any{"id": "resp_1"}},
40				{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
41				{"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
42				{"type": "response.completed", "response": map[string]any{"id": "resp_1", "usage": map[string]any{}}},
43			} {
44				_ = websocket.JSON.Send(conn, event)
45			}
46			var second map[string]any
47			if err := websocket.JSON.Receive(conn, &second); err != nil {
48				return
49			}
50			mu.Lock()
51			requests = append(requests, second)
52			mu.Unlock()
53			_ = websocket.JSON.Send(conn, map[string]any{
54				"type": "error", "status": 400,
55				"error": map[string]any{"code": "previous_response_not_found", "message": "evicted"},
56			})
57			return
58		}
59		var recovered map[string]any
60		if err := websocket.JSON.Receive(conn, &recovered); err != nil {
61			return
62		}
63		mu.Lock()
64		requests = append(requests, recovered)
65		mu.Unlock()
66		_ = websocket.JSON.Send(conn, map[string]any{"type": "response.created", "response": map[string]any{"id": "resp_2"}})
67		_ = websocket.JSON.Send(conn, map[string]any{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}})
68		_ = websocket.JSON.Send(conn, map[string]any{"type": "response.output_text.delta", "output_index": 0, "delta": "reconnected"})
69		_ = websocket.JSON.Send(conn, map[string]any{"type": "response.completed", "response": map[string]any{"id": "resp_2", "usage": map[string]any{}}})
70	})
71	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72		if r.Header.Get("Upgrade") == "websocket" {
73			ws.ServeHTTP(w, r)
74			return
75		}
76		t.Errorf("unexpected HTTP fallback: %s %s", r.Method, r.URL)
77		w.WriteHeader(http.StatusInternalServerError)
78	}))
79	defer server.Close()
80
81	client := newResponsesWebSocketClient(&codexClient{
82		token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
83		capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
84	})
85	first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
86	assertCompletedResponse(t, client, first)
87	lifecycle := &recordingRequestLifecycle{}
88	second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
89	assertCompletedResponseText(t, client, second, "reconnected")
90	mu.Lock()
91	defer mu.Unlock()
92	if visits != 2 || len(requests) != 3 {
93		t.Fatalf("WebSocket visits/requests = %d/%d, want 2/3", visits, len(requests))
94	}
95	if len(lifecycle.ids) != 2 || lifecycle.ids[0] == lifecycle.ids[1] {
96		t.Fatalf("reconnect request IDs = %#v, want two distinct IDs", lifecycle.ids)
97	}
98	if requests[2]["previous_response_id"] != nil {
99		t.Fatalf("reconnect previous_response_id = %#v, want absent", requests[2]["previous_response_id"])
100	}
101	recovered, _ := json.Marshal(requests[2]["input"])
102	if !strings.Contains(string(recovered), "first") || !strings.Contains(string(recovered), "second") {
103		t.Fatalf("reconnect did not receive complete context: %s", recovered)
104	}
105}
106
107func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
108	var (
109		mu       sync.Mutex
110		headers  []map[string]string
111		requests []map[string]any
112	)
113	server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
114		defer conn.Close()
115		for i := 0; i < 2; i++ {
116			var payload map[string]any
117			if err := websocket.JSON.Receive(conn, &payload); err != nil {
118				t.Errorf("receive websocket request: %v", err)
119				return
120			}
121			mu.Lock()
122			requests = append(requests, payload)
123			headers = append(headers, map[string]string{
124				"authorization":      conn.Request().Header.Get("authorization"),
125				"chatgpt-account-id": conn.Request().Header.Get("chatgpt-account-id"),
126				"openai-beta":        conn.Request().Header.Get("openai-beta"),
127			})
128			mu.Unlock()
129			_ = websocket.JSON.Send(conn, map[string]any{
130				"type": "response.created",
131				"response": map[string]any{
132					"id": "resp_" + string(rune('1'+i)),
133				},
134			})
135			_ = websocket.JSON.Send(conn, map[string]any{
136				"type":         "response.output_item.added",
137				"output_index": 0,
138				"item":         map[string]any{"type": "message"},
139			})
140			_ = websocket.JSON.Send(conn, map[string]any{
141				"type":         "response.output_text.delta",
142				"output_index": 0,
143				"delta":        "ok",
144			})
145			_ = websocket.JSON.Send(conn, map[string]any{
146				"type": "response.completed",
147				"response": map[string]any{
148					"id":    "resp_" + string(rune('1'+i)),
149					"usage": map[string]any{"input_tokens": 1, "output_tokens": 1},
150				},
151			})
152		}
153	}))
154	defer server.Close()
155
156	inner := &codexClient{
157		token:        "test-token",
158		baseURL:      server.URL + "/v1/responses",
159		providerName: "openai",
160		capabilities: responsesCapabilities{StablePromptCacheKey: true},
161	}
162	client := newResponsesWebSocketClient(inner)
163
164	lifecycle := &recordingRequestLifecycle{}
165	first := Request{
166		Model:     "gpt-5.6-sol",
167		FastMode:  true,
168		Context:   RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
169		Lifecycle: lifecycle,
170		Messages:  []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
171	}
172	assertCompletedResponse(t, client, first)
173	second := Request{
174		Model:    "gpt-5.6-sol",
175		FastMode: true,
176		Context:  RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
177		Messages: append(append([]Message(nil), first.Messages...),
178			Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
179			Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
180		),
181	}
182	assertCompletedResponse(t, client, second)
183	if len(lifecycle.ids) != 1 || lifecycle.ids[0] == "" {
184		t.Fatalf("WebSocket request IDs = %#v", lifecycle.ids)
185	}
186
187	mu.Lock()
188	defer mu.Unlock()
189	if len(requests) != 2 {
190		t.Fatalf("websocket requests = %d, want 2", len(requests))
191	}
192	if got := requests[0]["type"]; got != "response.create" {
193		t.Fatalf("first type = %#v", got)
194	}
195	if got := requests[0]["store"]; got != false {
196		t.Fatalf("store = %#v, want false", got)
197	}
198	if _, ok := requests[0]["stream"]; ok {
199		t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])
200	}
201	if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" {
202		t.Fatalf("prompt_cache_key = %#v", got)
203	}
204	if got := requests[0]["service_tier"]; got != fastModeServiceTier {
205		t.Fatalf("service_tier = %#v", got)
206	}
207	if got := requests[1]["previous_response_id"]; got != "resp_1" {
208		t.Fatalf("previous_response_id = %#v", got)
209	}
210	input, ok := requests[1]["input"].([]any)
211	if !ok || len(input) != 1 {
212		t.Fatalf("incremental input = %#v", requests[1]["input"])
213	}
214	encoded, _ := json.Marshal(input[0])
215	if !strings.Contains(string(encoded), "second") || strings.Contains(string(encoded), "ok") {
216		t.Fatalf("incremental input = %s", encoded)
217	}
218	for _, got := range headers {
219		if got["authorization"] != "Bearer test-token" {
220			t.Fatalf("authorization = %q", got["authorization"])
221		}
222		if got["chatgpt-account-id"] != "" || got["openai-beta"] != "" {
223			t.Fatalf("public WebSocket leaked Codex headers: %#v", got)
224		}
225	}
226}
227
228func TestOpenAICodexUsesWebSocketWithSubscriptionHeaders(t *testing.T) {
229	headers := make(chan http.Header, 1)
230	requests := make(chan map[string]any, 1)
231	server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
232		defer conn.Close()
233		headers <- conn.Request().Header.Clone()
234		var payload map[string]any
235		if err := websocket.JSON.Receive(conn, &payload); err != nil {
236			t.Errorf("receive websocket request: %v", err)
237			return
238		}
239		requests <- payload
240		for _, event := range []map[string]any{
241			{"type": "response.created", "response": map[string]any{"id": "resp_subscription"}},
242			{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
243			{"type": "response.output_text.delta", "output_index": 0, "delta": "ok"},
244			{"type": "response.completed", "response": map[string]any{"id": "resp_subscription", "usage": map[string]any{}}},
245		} {
246			_ = websocket.JSON.Send(conn, event)
247		}
248	}))
249	defer server.Close()
250
251	client := NewOpenAICodex("subscription-token", "account-1", server.URL)
252	websocketClient, ok := client.(*responsesWebSocketClient)
253	if !ok {
254		t.Fatalf("NewOpenAICodex returned %T, want WebSocket Responses client", client)
255	}
256	defer websocketClient.Close()
257
258	request := Request{
259		Model:    "gpt-5.6-luna",
260		Context:  RequestContext{CacheSessionID: "root-1", ThreadID: "thread-1", TurnID: "turn-1"},
261		Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}},
262	}
263	assertCompletedResponse(t, client, request)
264
265	gotHeaders := <-headers
266	for name, want := range map[string]string{
267		"authorization":       "Bearer subscription-token",
268		"chatgpt-account-id":  "account-1",
269		"openai-beta":         "responses=experimental",
270		"originator":          "codex_cli_rs",
271		"user-agent":          "codex_cli_rs/0.0.0",
272		"session-id":          "root-1",
273		"thread-id":           "thread-1",
274		"x-client-request-id": "thread-1",
275	} {
276		if got := gotHeaders.Get(name); got != want {
277			t.Fatalf("%s = %q, want %q", name, got, want)
278		}
279	}
280	if got := gotHeaders.Get("origin"); got != server.URL {
281		t.Fatalf("origin = %q, want %q", got, server.URL)
282	}
283	if got := (<-requests)["prompt_cache_key"]; got != "root-1" {
284		t.Fatalf("prompt_cache_key = %#v, want root cache ID", got)
285	}
286}
287
288func TestWithHTTPClientPreservesSubscriptionHeaders(t *testing.T) {
289	client := NewOpenAICodex("subscription-token", "account-1", "https://example.test/backend-api/codex/responses")
290	customHTTP := &http.Client{Transport: &http.Transport{}}
291	WithHTTPClient(client, customHTTP)
292
293	websocketClient := client.(*responsesWebSocketClient)
294	if websocketClient.http.http != customHTTP {
295		t.Fatal("subscription client was wrapped in the public OpenAI header-stripping transport")
296	}
297}
298
299func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
300	mappedModel := "gpt-5.6-sol-a"
301	client := newResponsesWebSocketClient(&codexClient{
302		providerName: "openai",
303		capabilities: responsesCapabilities{StablePromptCacheKey: true},
304		modelName:    func(string) string { return mappedModel },
305	}).(*responsesWebSocketClient)
306	first := Request{
307		Model:    "gpt-5.6-sol",
308		Context:  RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
309		Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
310	}
311	baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
312	if err != nil {
313		t.Fatal(err)
314	}
315	session := &responsesWebSocketSession{}
316	session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, "resp_1")
317
318	mappedModel = "gpt-5.6-sol-b"
319	second := Request{
320		Model:   first.Model,
321		Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
322		Messages: append(append([]Message(nil), first.Messages...),
323			Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
324			Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
325		),
326	}
327	streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
328	if previousResponseID != "" {
329		t.Fatalf("previous response ID = %q, want full request after wire baseline change", previousResponseID)
330	}
331	if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
332		t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
333	}
334}
335
336func TestResponsesWebSocketAssistantMismatchForcesCompleteTranscript(t *testing.T) {
337	client := newResponsesWebSocketClient(&codexClient{
338		providerName: "openai", capabilities: responsesCapabilities{StablePromptCacheKey: true},
339	}).(*responsesWebSocketClient)
340	first := Request{
341		Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
342		Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
343	}
344	baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
345	if err != nil {
346		t.Fatal(err)
347	}
348	session := &responsesWebSocketSession{}
349	session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "actual"}}}, "resp_1")
350	second := Request{
351		Model: first.Model, Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
352		Messages: append(append([]Message(nil), first.Messages...),
353			Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "modified"}}},
354			Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
355		),
356	}
357	streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
358	if previousResponseID != "" {
359		t.Fatalf("previous response ID = %q, want complete request", previousResponseID)
360	}
361	if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
362		t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
363	}
364}
365
366func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) {
367	closed := make(chan struct{})
368	var closeOnce sync.Once
369	server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
370		defer closeOnce.Do(func() { close(closed) })
371		defer conn.Close()
372		var payload map[string]any
373		if err := websocket.JSON.Receive(conn, &payload); err != nil {
374			return
375		}
376		var ignored any
377		_ = websocket.JSON.Receive(conn, &ignored)
378	}))
379	defer server.Close()
380
381	client := newResponsesWebSocketClient(&codexClient{
382		token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
383		capabilities: responsesCapabilities{StablePromptCacheKey: true},
384	})
385	ctx, cancel := context.WithCancel(context.Background())
386	stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}})
387	if err != nil {
388		t.Fatal(err)
389	}
390	cancel()
391	for range stream {
392	}
393	select {
394	case <-closed:
395	case <-time.After(time.Second):
396		t.Fatal("cancellation did not close active WebSocket")
397	}
398}
399
400func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testing.T) {
401	server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
402		defer conn.Close()
403		for turn := 1; turn <= 2; turn++ {
404			var payload map[string]any
405			if err := websocket.JSON.Receive(conn, &payload); err != nil {
406				return
407			}
408			if turn == 2 {
409				// A buffered terminal from the prior response must not complete
410				// this response before its response.created frame arrives.
411				_ = websocket.JSON.Send(conn, map[string]any{"type": "response.completed", "response": map[string]any{"id": "resp_1", "usage": map[string]any{}}})
412			}
413			id := "resp_" + string(rune('0'+turn))
414			text := "first"
415			if turn == 2 {
416				text = "second"
417			}
418			for _, event := range []map[string]any{
419				{"type": "response.created", "response": map[string]any{"id": id}},
420				{"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "message"}},
421				{"type": "response.output_text.delta", "output_index": 0, "delta": text},
422				{"type": "response.completed", "response": map[string]any{"id": id, "usage": map[string]any{}}},
423			} {
424				_ = websocket.JSON.Send(conn, event)
425			}
426		}
427	}))
428	defer server.Close()
429
430	client := newResponsesWebSocketClient(&codexClient{
431		token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
432		capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
433	})
434	first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
435	assertCompletedResponseText(t, client, first, "first")
436	second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
437	assertCompletedResponseText(t, client, second, "second")
438}
439
440func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) {
441	client := newResponsesWebSocketClient(&codexClient{}).(*responsesWebSocketClient)
442	client.session("resident-child")
443	if err := client.Close(); err != nil {
444		t.Fatalf("close responses websocket client: %v", err)
445	}
446	client.mu.Lock()
447	defer client.mu.Unlock()
448	if len(client.sessions) != 0 {
449		t.Fatalf("sessions retained after close: %d", len(client.sessions))
450	}
451}
452
453type cacheDiagnosticsLifecycle struct {
454	diagnostics []CacheDiagnostics
455}
456
457func (l *cacheDiagnosticsLifecycle) RequestAttempt(int, int)                {}
458func (l *cacheDiagnosticsLifecycle) RetryScheduled(int, int, time.Duration) {}
459
460func (l *cacheDiagnosticsLifecycle) CacheDiagnostics(diagnostics CacheDiagnostics) {
461	l.diagnostics = append(l.diagnostics, diagnostics)
462}
463
464func TestResponsesWebSocketUsesHTTPDiagnosticsForIncompatibleTransport(t *testing.T) {
465	lifecycle := &cacheDiagnosticsLifecycle{}
466	client := newResponsesWebSocketClient(&codexClient{
467		token: "test-token", baseURL: "https://example.test/v1/responses", providerName: "openai",
468		capabilities: responsesCapabilities{StablePromptCacheKey: true},
469		http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
470			return &http.Response{
471				StatusCode: http.StatusOK,
472				Header:     make(http.Header),
473				Body: io.NopCloser(strings.NewReader(`data: {"type":"response.completed","response":{"status":"completed","usage":{}}}
474
475`)),
476			}, nil
477		})},
478	})
479	req := Request{
480		Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread"}, Lifecycle: lifecycle,
481		Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}},
482	}
483	stream, err := client.Stream(context.Background(), req)
484	if err != nil {
485		t.Fatal(err)
486	}
487	for range stream {
488	}
489	if len(lifecycle.diagnostics) != 1 || lifecycle.diagnostics[0].Transport != "http_sse" {
490		t.Fatalf("diagnostics = %#v, want only HTTP/SSE diagnostics", lifecycle.diagnostics)
491	}
492}
493
494func assertCompletedResponse(t *testing.T, client Client, req Request) {
495	assertCompletedResponseText(t, client, req, "ok")
496}
497
498func assertCompletedResponseText(t *testing.T, client Client, req Request, want string) {
499	t.Helper()
500	stream, err := client.Stream(context.Background(), req)
501	if err != nil {
502		t.Fatal(err)
503	}
504	var done EventDone
505	var got []Event
506	for event := range stream {
507		got = append(got, event)
508		if got, ok := event.(EventDone); ok {
509			done = got
510		}
511	}
512	if done.Err != nil || len(done.Message.Content) != 1 || done.Message.Content[0].(TextBlock).Text != want {
513		t.Fatalf("events = %#v; done = %#v", got, done)
514	}
515}

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