render_pipeline_test.go RawHistoryFolder

1package daemon
2
3import (
4	"go/ast"
5	"go/parser"
6	"go/token"
7	"io"
8	"os"
9	"path/filepath"
10	"strings"
11	"testing"
12	"time"
13
14	vt "github.com/bnema/vev-vt"
15	renderer "github.com/bnema/vev-vt/ansi"
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/domain/layout"
18	"github.com/bnema/vev/internal/protocol"
19	"github.com/bnema/vev/internal/protocol/wire"
20	"github.com/bnema/vev/internal/testutil/replaytest"
21	scopy "github.com/bnema/vev/internal/usecase/copy"
22	"github.com/bnema/vev/internal/usecase/notices"
23	"github.com/bnema/vev/internal/usecase/palette"
24	promptui "github.com/bnema/vev/internal/usecase/prompt"
25	themeui "github.com/bnema/vev/internal/usecase/theme"
26	"github.com/bnema/vev/internal/usecase/ui"
27	"github.com/bnema/vev/internal/usecase/visualsearch"
28	"github.com/stretchr/testify/require"
29)
30
31// scriptedReplayTransport is intentionally a wire.Transport only: replay
32// assertions must not depend on any adapter's framing implementation.
33type scriptedReplayTransport struct {
34	t      *testing.T
35	frames []wire.Envelope
36	next   int
37}
38
39func (s *scriptedReplayTransport) Send(got wire.Envelope) error {
40	s.t.Helper()
41	if s.next >= len(s.frames) {
42		s.t.Errorf("unexpected frame %#v", got)
43		return nil
44	}
45	want := s.frames[s.next]
46	s.next++
47	require.Equal(s.t, want.Payload, got.Payload)
48	return nil
49}
50func (*scriptedReplayTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
51func (*scriptedReplayTransport) Close() error                 { return nil }
52
53func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
54	frames := replaytest.Transcript()
55	transport := &scriptedReplayTransport{t: t, frames: frames}
56	terminal := vt.NewScreen(8, 3)
57	for _, frame := range frames {
58		require.NoError(t, transport.Send(frame))
59		output := unmarshalTestOutput(t, frame.Payload)
60		require.Equal(t, frame.Payload, mustServerEnvelope(output).Payload, "output payload must remain byte exact")
61		terminal.Write(output.Data)
62	}
63	require.Equal(t, len(frames), transport.next)
64	require.Equal(t, []string{"one     ", "TWO     ", "        "}, frameRows(terminal), "terminal replay is the final renderer shadow")
65}
66
67func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
68	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
69	p.screen.ClearDamage()
70	p.screen.Write([]byte("old"))
71
72	p.mu.Lock()
73	captured := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 3, Height: 1}, capturedPaneRenderState{})
74	p.mu.Unlock()
75
76	require.Equal(t, 3, captured.frame.Width)
77	require.Equal(t, 1, captured.frame.Height)
78	require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
79	p.mu.Lock()
80	p.screen.Write([]byte("new"))
81	p.mu.Unlock()
82	require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
83}
84
85func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
86	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
87	p.screen.ClearDamage()
88	p.screen.Write([]byte("preview"))
89
90	p.mu.Lock()
91	_ = capturePaneRenderStateLockedInto(p, domain.Rect{Width: 8, Height: 2}, capturedPaneRenderState{})
92	p.mu.Unlock()
93
94	require.NotEmpty(t, p.screen.Damage())
95}
96
97func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
98	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
99	p.screen.ClearDamage()
100	p.screen.Write([]byte("x"))
101	p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
102
103	p.mu.Lock()
104	captured := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 8, Height: 2}, capturedPaneRenderState{})
105	p.mu.Unlock()
106
107	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
108}
109
110func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
111	p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
112	p.screen.ClearDamage()
113	p.screen.Write([]byte("hidden"))
114
115	p.mu.Lock()
116	captured := capturePaneRenderStateLockedInto(p, domain.Rect{}, capturedPaneRenderState{})
117	p.mu.Unlock()
118
119	require.Zero(t, captured.frame.Width)
120	require.Zero(t, captured.frame.Height)
121	require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
122}
123
124func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
125	pty, release := newBlockingPTY(t)
126	t.Cleanup(release)
127	d, sess, _, sends := newManualSessionWithPTYs(t, pty)
128	ac := sess.snapshotAttachments()[0]
129	// Resolve the initial attachment view before filling the output window; a
130	// first target repair is an epoch boundary by design.
131	require.NotNil(t, sess.tabForAttachment(ac))
132	ac.sendMu.Lock()
133	ac.output.next = ac.output.maxOutstanding
134	ac.output.acked = 0
135	ac.output.syncCapacityLocked()
136	ac.sendMu.Unlock()
137	p := sess.tabs[0].focusedPane()
138	p.mu.Lock()
139	p.screen.ClearDamage()
140	p.screen.Write([]byte("blocked"))
141	p.mu.Unlock()
142
143	d.paint(sess, ac, false, nil)
144
145	p.mu.Lock()
146	require.NotEmpty(t, p.screen.Damage())
147	p.mu.Unlock()
148	select {
149	case frame := <-sends:
150		t.Fatalf("ACK-blocked paint sent %#v", frame)
151	default:
152	}
153}
154
155func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
156	state := &capturedRenderState{
157		layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
158		panes: []capturedPaneRenderState{{
159			id: "p", frame: func() renderer.Frame {
160				f := renderer.NewFrame(4, 1)
161				for i, r := range "old " {
162					f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
163				}
164				return f
165			}(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
166			damage: []renderer.Damage{renderer.FullRedraw()},
167		}},
168		bars:   barState{},
169		cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
170	}
171	cache := composeCacheInput{}
172	out := composeFrame(*state, cache)
173	state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
174	require.Equal(t, "old ", rowText(out.frame.Row(1)))
175	require.False(t, out.cursor.hidden)
176}
177
178func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
179	files, err := filepath.Glob("*.go")
180	require.NoError(t, err)
181	for _, name := range files {
182		parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
183		require.NoError(t, err)
184		for _, declaration := range parsed.Decls {
185			switch declaration := declaration.(type) {
186			case *ast.FuncDecl:
187				forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
188				require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
189			case *ast.GenDecl:
190				for _, spec := range declaration.Specs {
191					typeSpec, ok := spec.(*ast.TypeSpec)
192					require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
193				}
194			}
195		}
196	}
197	data, err := os.ReadFile("render_pipeline.go")
198	require.NoError(t, err)
199	body := string(data)
200	start := strings.Index(body, "func composeFrame(")
201	require.NotEqual(t, -1, start)
202	end := strings.Index(body[start:], "\nfunc ")
203	if end > 0 {
204		body = body[start : start+end]
205	} else {
206		body = body[start:]
207	}
208	for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
209		require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
210	}
211}
212
213func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
214	output := &attachmentOutput{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
215	desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
216
217	candidate := output.prepareCursorTail(desired, false)
218
219	require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, output.lastCursor)
220	require.NotEmpty(t, candidate.data)
221	require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
222}
223
224func TestPublishFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
225	d, sess, ac, sends := newManualSessionWithPTYs(t)
226	healthy := ac.transport()
227	beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
228	ac.output.lastCursor = beforeCursor
229	ac.replaceTransport(cacheFailTransport{})
230	state := cacheState("failed", 1)
231	state.attachment = ac
232	composed := composeFrame(state, ac.render.cache, ac.render.spare)
233	composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
234
235	ac.sendMu.Lock()
236	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed))
237
238	require.Equal(t, beforeCursor, ac.output.lastCursor)
239	require.Zero(t, ac.output.next)
240	probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
241	require.NoError(t, err)
242	require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
243
244	sess.mu.Lock()
245	sess.registerAttachmentLocked(ac)
246	sess.mu.Unlock()
247	ac.setSession(sess)
248	ac.replaceTransport(healthy)
249	state.attachment = ac
250	ac.sendMu.Lock()
251	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed))
252	require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.output.lastCursor)
253	require.Equal(t, uint64(1), ac.output.next)
254	out := unmarshalTestOutput(t, (<-sends).Payload)
255	require.Zero(t, out.Base)
256	require.Equal(t, uint64(1), out.New)
257}
258
259func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
260	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
261	state := cacheState("steady", 1)
262	state.attachment = ac
263	state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
264	state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)
265	state.view.revision = ac.viewSnapshot().revision
266	initial := composeFrame(state, ac.render.cache, ac.render.spare)
267	ac.sendMu.Lock()
268	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, initial))
269	<-sends
270
271	p := newPane("collapsed", nil, domain.Size{Cols: 1, Rows: 1})
272	p.mu.Lock()
273	p.screen.ClearDamage()
274	p.screen.Write([]byte("x"))
275	capture := p.screen.CaptureDamage()
276	p.mu.Unlock()
277
278	beforeNext := ac.output.next
279	beforeCursor := ac.output.lastCursor
280	state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
281	noByte := composedRenderFrame{
282		frame:  initial.frame,
283		cursor: initial.cursor,
284		cache:  initial.cache,
285	}
286	noByte.cache.layoutFingerprint = "no-byte-committed"
287	ac.sendMu.Lock()
288	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, noByte))
289
290	require.Equal(t, beforeNext, ac.output.next)
291	require.Equal(t, beforeCursor, ac.output.lastCursor)
292	require.Equal(t, "no-byte-committed", ac.render.cache.layoutFingerprint)
293	p.mu.Lock()
294	require.Empty(t, p.screen.Damage())
295	p.mu.Unlock()
296	select {
297	case frame := <-sends:
298		t.Fatalf("no-byte transaction sent state frame %#v", frame)
299	default:
300	}
301
302	state.focusedPaneID = "pane-2"
303	state.panes[0].stableID = "pane-2"
304	ac.sendMu.Lock()
305	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, noByte))
306	var frame wire.Envelope
307	select {
308	case frame = <-sends:
309	case <-time.After(time.Second):
310		t.Fatal("timed out waiting for UI view update")
311	}
312	require.Equal(t, "UIViewUpdate", envelopeMessageName(t, frame.Payload))
313	update, ok := decodeServerMessage(t, frame).(protocol.UIViewUpdate)
314	require.True(t, ok)
315	require.Equal(t, state.focusedPaneID, update.Context.FocusedPaneID)
316}
317
318func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
319	paneFrame := renderer.NewFrame(12, 5)
320	for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
321		for x, r := range text {
322			paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
323		}
324	}
325	floatingFrame := renderer.NewFrame(4, 1)
326	for x, r := range "FLOAT"[:4] {
327		floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
328	}
329	modalInner := renderer.NewFrame(6, 1)
330	for x, r := range "PROMPT" {
331		modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
332	}
333	state := capturedRenderState{
334		route:    protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sess"}},
335		view:     attachmentView{tabID: "tab-1"},
336		reset:    true,
337		layout:   capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
338		panes:    []capturedPaneRenderState{{id: "p", stableID: "pane-1", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
339		floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
340		bars:     barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
341		overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
342		cursor:   capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
343		styles:   resolveStyles(nil),
344	}
345	composed := composeFrame(state, composeCacheInput{})
346	require.Equal(t, []string{" tab       R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT      ", "            ", " sess      B"}, frameRows(composed.frame))
347	require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
348
349	stream := newOutputStateStream()
350	prepared, err := stream.prepareFrame(nil, &state, composed.frame, composed.damage, composed.reset, composed.cursor)
351	require.NoError(t, err)
352	var outputFrame wire.Envelope
353	require.NoError(t, prepared.send(0, outputFrameSender(func(frame wire.Envelope) error {
354		outputFrame = frame
355		return nil
356	})))
357	output := unmarshalTestOutput(t, outputFrame.Payload)
358	require.NoError(t, err)
359	terminalBytes := output.Data
360	require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m      R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m     B\x1b[0m\x1b[?25l", string(terminalBytes))
361	client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
362	client.Write(terminalBytes)
363	require.Equal(t, frameRows(composed.frame), frameRows(client))
364	again, err := stream.renderer.Draw(composed.frame, nil)
365	require.NoError(t, err)
366	require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
367}
368
369func frameRows(frame renderer.CellSource) []string {
370	rows := make([]string, frame.Rows())
371	cells := make([]renderer.Cell, frame.Columns())
372	for y := range rows {
373		for x := range cells {
374			cells[x] = frame.Cell(x, y)
375		}
376		rows[y] = rowText(cells)
377	}
378	return rows
379}
380
381func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
382	for _, tt := range []struct {
383		name      string
384		placement layout.Placement
385		initial   []string
386		scrolled  []string
387		damage    []renderer.Damage
388		wantBytes string
389	}{
390		{name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N   "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
391		{name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
392	} {
393		t.Run(tt.name, func(t *testing.T) {
394			makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
395				f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
396				for y, text := range rows {
397					for x, r := range text {
398						f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
399					}
400				}
401				return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
402			}
403			stream := newOutputStateStream()
404			first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
405			firstBytes, err := stream.render(first.frame, first.damage, first.reset)
406			require.NoError(t, err)
407			client := vt.NewScreen(4, 5)
408			client.Write(firstBytes)
409
410			second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
411			secondBytes, err := stream.render(second.frame, second.damage, second.reset)
412			require.NoError(t, err)
413			require.Equal(t, tt.wantBytes, string(secondBytes))
414			client.Write(secondBytes)
415			require.Equal(t, frameRows(second.frame), frameRows(client))
416			again, err := stream.renderer.Draw(second.frame, nil)
417			require.NoError(t, err)
418			require.Empty(t, again, "renderer shadow must equal replayed terminal state")
419		})
420	}
421}
422
423func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
424	muted := renderer.Style{Foreground: 1, Background: 2}
425	active := renderer.Style{Foreground: 3, Background: 4}
426	interior := renderer.Style{Foreground: 5, Background: 6}
427	modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
428	presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
429
430	for _, tt := range []struct {
431		name    string
432		focused bool
433		border  renderer.Style
434	}{
435		{name: "unfocused", focused: false, border: muted},
436		{name: "focused", focused: true, border: active},
437	} {
438		t.Run(tt.name, func(t *testing.T) {
439			state := capturedRenderState{
440				styles:   themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
441				overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
442			}
443			frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
444			bounds := presentation.Bounds
445			require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
446			require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
447		})
448	}
449}
450
451func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
452	const (
453		width       = 100
454		contentRows = 6
455	)
456	styles := resolveStyles(nil)
457	theme := backdropTheme()
458	leftPlacement := layout.Placement{
459		ID:       "left",
460		TitleBar: domain.Rect{Width: 49, Height: 1},
461		Content:  domain.Rect{Y: 1, Width: 49, Height: 5},
462	}
463	rightPlacement := layout.Placement{
464		ID:       "right",
465		TitleBar: domain.Rect{X: 50, Width: 50, Height: 1},
466		Content:  domain.Rect{X: 50, Y: 1, Width: 50, Height: 5},
467	}
468	state := capturedRenderState{
469		reset: true,
470		layout: capturedTabLayout{
471			area:        domain.Rect{Width: width, Height: contentRows},
472			focus:       "left",
473			placements:  []layout.Placement{leftPlacement, rightPlacement},
474			dividers:    []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}},
475			fingerprint: "modal-backdrop",
476			valid:       true,
477		},
478		panes: []capturedPaneRenderState{
479			{id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}},
480			{id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}},
481		},
482		overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
483		styles:   styles,
484		theme:    theme,
485	}
486	base := composeFrame(state, composeCacheInput{})
487	require.NotEmpty(t, base.cache.toastFootprints)
488
489	state.reset = false
490	state.panes[0].damage = nil
491	state.panes[1].damage = nil
492	state.overlays.promptActive = true
493	state.overlays.prompt = capturedModal{
494		active: true,
495		title:  "Prompt",
496		presentation: ui.Presentation{
497			Bounds:  domain.Rect{X: 80, Y: 5, Width: 10, Height: 2},
498			Inner:   domain.Rect{X: 81, Y: 6, Width: 8, Height: 1},
499			Borders: ui.BorderAll,
500		},
501		inner:   renderer.NewFrame(8, 1),
502		focused: true,
503	}
504	composed := composeFrame(state, base.cache)
505	dimmer := themeui.NewDimmer(theme)
506	toast := base.cache.toastFootprints[0]
507
508	for name, point := range map[string][2]int{
509		"top bar":      {0, 0},
510		"pane chrome":  {0, 1},
511		"pane content": {0, 2},
512		"divider":      {49, 2},
513		"bottom bar":   {0, contentRows + 1},
514		"toast":        {toast.X, toast.Y},
515	} {
516		t.Run(name, func(t *testing.T) {
517			x, y := point[0], point[1]
518			require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
519		})
520	}
521	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
522	require.False(t, composed.cache.valid)
523	require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned")
524}
525
526func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
527	const (
528		width       = 100
529		contentRows = 6
530	)
531	placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}}
532	theme := backdropTheme()
533	state := capturedRenderState{
534		reset:  true,
535		layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true},
536		panes: []capturedPaneRenderState{{
537			id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
538		}},
539		overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
540		styles:   resolveStyles(nil),
541		theme:    theme,
542	}
543	toastOnly := composeFrame(state, composeCacheInput{})
544	require.NotEmpty(t, toastOnly.cache.toastFootprints)
545	toast := toastOnly.cache.toastFootprints[0]
546
547	state.floating = capturedFloatingRenderState{
548		visible: true,
549		focused: true,
550		pane:    capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)},
551		geometry: floatingGeometry{
552			Mode:   ui.PresentationFloating,
553			Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3},
554			Inner:  domain.Rect{X: 41, Y: 3, Width: 8, Height: 1},
555		},
556		generation: 1,
557	}
558	composed := composeFrame(state, composeCacheInput{})
559	dimmer := themeui.NewDimmer(theme)
560
561	for name, point := range map[string][2]int{
562		"top bar":    {0, 0},
563		"bottom bar": {0, contentRows + 1},
564		"toast":      {toast.X, toast.Y},
565	} {
566		t.Run(name, func(t *testing.T) {
567			x, y := point[0], point[1]
568			require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
569		})
570	}
571	require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache")
572}
573
574func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
575	theme := backdropTheme()
576	lowerStyle := renderer.Style{Foreground: 1, Background: 2}
577	higherStyle := renderer.Style{Foreground: 3, Background: 4}
578	modalAt := func(x int, r rune, style renderer.Style) capturedModal {
579		inner := renderer.NewFrame(1, 1)
580		inner.Set(0, 0, renderer.Cell{Rune: r, Style: style})
581		bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1}
582		return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner}
583	}
584
585	for _, tt := range []struct {
586		name   string
587		lower  string
588		higher string
589	}{
590		{name: "notices above copy search", lower: "copy search", higher: "notices"},
591		{name: "palette above notices", lower: "notices", higher: "palette"},
592		{name: "prompt above palette", lower: "palette", higher: "prompt"},
593	} {
594		t.Run(tt.name, func(t *testing.T) {
595			state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}}
596			setModal := func(name string, modal capturedModal) {
597				switch name {
598				case "copy search":
599					state.overlays.copySearch = modal
600				case "notices":
601					state.overlays.noticesOverlay = modal
602				case "palette":
603					state.overlays.palette = modal
604				case "prompt":
605					state.overlays.prompt = modal
606				}
607			}
608			setModal(tt.lower, modalAt(1, 'L', lowerStyle))
609			setModal(tt.higher, modalAt(3, 'H', higherStyle))
610
611			frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
612
613			require.Equal(t, 'L', frame.At(1, 1).Rune)
614			require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop")
615			require.Equal(t, 'H', frame.At(3, 1).Rune)
616			require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
617			require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
618		})
619	}
620}
621
622func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
623	state := capturedRenderState{
624		layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
625		styles: fallbackChromeStyles,
626	}
627	snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
628	snap := &overlayRenderSnapshot{
629		copySearchModel:      visualsearch.New(snapshot),
630		noticesOverlayActive: true,
631		noticesOverlayModel:  notices.New(nil, time.Time{}),
632		paletteActive:        true,
633		paletteModel:         palette.New(nil),
634		promptActive:         true,
635		promptModel:          promptui.New(" Prompt ", ""),
636	}
637
638	captureOverlayLayers(&state, snap, domain.PaletteConfig{})
639
640	for name, modal := range map[string]capturedModal{
641		"copy search": state.overlays.copySearch,
642		"notices":     state.overlays.noticesOverlay,
643		"palette":     state.overlays.palette,
644		"prompt":      state.overlays.prompt,
645	} {
646		t.Run(name, func(t *testing.T) {
647			require.True(t, modal.active)
648			require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
649			require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
650		})
651	}
652}
653
654func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
655	base := renderer.NewFrame(4, 3)
656	inner := renderer.NewFrame(4, 2)
657	for y, text := range []string{"ABCD", "EFGH"} {
658		for x, r := range text {
659			inner.Set(x, y, renderer.Cell{Rune: r})
660		}
661	}
662
663	copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
664
665	require.Equal(t, "BCD ", rowText(base.Row(2)))
666}
667
668func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
669	presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
670	require.Zero(t, presentation.Bounds.Height)
671	state := capturedRenderState{
672		styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
673		overlays: capturedOverlayRenderState{
674			copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
675		},
676	}
677
678	_, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil)
679
680	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
681}
682
683func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
684	terminal := renderer.DefaultStyle()
685	muted := renderer.Style{Foreground: 1}
686	active := renderer.Style{Foreground: 2}
687	warn := renderer.Style{Foreground: 3}
688	styles := themeui.Styles{PickerBase: terminal, BorderMuted: muted, BorderActive: active, BorderWarn: warn}
689
690	got := noticeStylesFrom(styles)
691
692	require.True(t, got.Text.Equal(terminal), "toast content keeps the terminal background")
693	require.Equal(t, active, got.BoxError)
694	require.Equal(t, muted, got.BoxInfo)
695	// BoxWarn must use the dedicated BorderWarn role, not fall back to the
696	// same muted role BoxInfo uses - that was the pre-existing bug this
697	// role fixes (Warn and Info toasts were visually identical).
698	require.Equal(t, warn, got.BoxWarn)
699	require.NotEqual(t, got.BoxInfo, got.BoxWarn)
700}
701
702func TestPublishFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
703	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
704	token := sess.captureAttachmentCapability(ac, ac.transport())
705	effect, admitted := ac.beginAttachmentEffect(token)
706	require.True(t, admitted)
707	ac.lifecycle.mu.Lock()
708	ac.lifecycle.capability.transport = transportSnapshot{}
709	ac.lifecycle.mu.Unlock()
710
711	state := cacheState("stale", 1)
712	state.attachment = ac
713	composed := composeFrame(state, composeCacheInput{})
714	ac.sendMu.Lock()
715	require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed, &runtimeMarkBatch{attachmentEffect: effect}))
716	d.attachmentCleanupWg.Wait()
717
718	require.Zero(t, ac.output.next, "rejected transport effect must not commit output state")
719	select {
720	case frame := <-sends:
721		t.Fatalf("rejected transport effect was sent: %s", envelopeMessageName(t, frame.Payload))
722	default:
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 12

Tags 11

↑↓ moveenter openesc close