main ece7682

status_test.go RawHistoryFolder

1package daemon
2
3import (
4	"fmt"
5	"strconv"
6	"strings"
7	"sync"
8	"testing"
9	"time"
10	"unicode/utf8"
11
12	"github.com/stretchr/testify/require"
13
14	renderer "github.com/bnema/vev-vt/ansi"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/domain/layout"
17	"github.com/bnema/vev/internal/protocol"
18	"github.com/bnema/vev/internal/protocol/wire"
19	"github.com/bnema/vev/internal/usecase/command"
20	"github.com/bnema/vev/internal/usecase/palette"
21	themeui "github.com/bnema/vev/internal/usecase/theme"
22	"github.com/bnema/vev/internal/usecase/ui"
23)
24
25// stubClock returns timers whose channel never fires, so a scheduler under it
26// blocks in its debounce loop until the session context is cancelled. Used by
27
28func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
29	p, releasePTY := newBlockingPTY(t)
30	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
31	defer releasePTY()
32	d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
33
34	win := testAttachmentTab(sess)
35	left := win.focusedPane()
36	right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
37	win.mu.Lock()
38	win.size = domain.Size{Cols: 41, Rows: 4}
39	win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
40	win.tree.Focus = left.id
41	win.panes[right.id] = right
42	win.mu.Unlock()
43	left.mu.Lock()
44	left.screen.Resize(20, 4)
45	left.screen.Write([]byte("L"))
46	left.mu.Unlock()
47	right.mu.Lock()
48	right.screen.Resize(20, 4)
49	right.screen.Write([]byte("R"))
50	right.mu.Unlock()
51
52	d.paint(sess, ac, true, nil)
53	ac.sendMu.Lock()
54	require.True(t, ac.render.cache.valid)
55	lightDimmedPane := ac.render.cache.frame.At(21, 1).Style
56	lightDivider := ac.render.cache.frame.At(20, 1).Style
57	ac.sendMu.Unlock()
58	left.mu.Lock()
59	left.screen.ClearDamage()
60	left.mu.Unlock()
61	right.mu.Lock()
62	right.screen.ClearDamage()
63	right.mu.Unlock()
64
65	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
66
67	ac.sendMu.Lock()
68	defer ac.sendMu.Unlock()
69	require.True(t, ac.render.cache.valid, "reset=false config repaint should rebuild the composed cache")
70	require.NotEqual(t, lightDimmedPane, ac.render.cache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
71	require.NotEqual(t, lightDivider, ac.render.cache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
72}
73
74func TestStatusCompositionGolden(t *testing.T) {
75	p1, releasePTY1 := newBlockingPTY(t)
76	p2, releasePTY2 := newBlockingPTY(t)
77	_, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
78	defer releasePTY1()
79	defer releasePTY2()
80	selectTestAttachmentTab(sess, 1)
81	sess.name = "work"
82
83	win := testAttachmentTab(sess)
84	win.focusedPane().screen.Resize(12, 2)
85	win.size = domain.Size{Cols: 12, Rows: 2}
86	win.focusedPane().screen.Write([]byte("hello"))
87
88	frameState := capturedRenderState{
89		reset:  true,
90		layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
91		panes: []capturedPaneRenderState{{
92			id: win.focusedPane().id, frame: captureTestFrame(win.focusedPane().screen), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
93		}},
94		bars: barState{status: sess.statusSegmentsFor(ac, true)},
95	}
96	composed := composeFrame(frameState, composeCacheInput{})
97	frame, damage := composed.frame, composed.damage
98
99	require.Equal(t, 12, frame.Width)
100	require.Equal(t, 4, frame.Height)
101	require.Equal(t, " 1  2       ", rowText(frame.Row(0)))
102	require.Equal(t, "hello       ", rowText(frame.Row(1)))
103	require.Equal(t, " work       ", rowText(frame.Row(3)))
104	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
105	for i, c := range frame.Row(0) {
106		if i >= len(" 1 ") && i < len(" 1  2 ") {
107			require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
108		}
109	}
110}
111
112func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) {
113	tests := []struct {
114		name             string
115		routeLifecycle   domain.SessionLifecycleID
116		wantPresentation string
117	}{
118		{name: "matching lifecycle", routeLifecycle: domain.SessionLifecycleID{1}, wantPresentation: "vive@arch"},
119		{name: "stale lifecycle", routeLifecycle: domain.SessionLifecycleID{99}, wantPresentation: "vive"},
120	}
121	for _, tt := range tests {
122		t.Run(tt.name, func(t *testing.T) {
123			p, releasePTY := newBlockingPTY(t)
124			_, sess, ac, _ := newManualSessionWithPTYs(t, p)
125			defer releasePTY()
126
127			sess.name = "vive"
128			sess.incarnation = domain.SessionLifecycleID{1}
129			ref := protocol.RouteRef{Key: 1, Generation: 1}
130			ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
131				Generation: 1,
132				Active:     ref,
133				ActiveEntry: protocol.RecentRouteEntry{
134					Key: ref.Key, Generation: ref.Generation,
135					Target: protocol.ExactSessionTarget{LifecycleID: tt.routeLifecycle, SessionName: sess.name},
136					Name:   "vive", HostLabel: "user@arch", Kind: protocol.RouteKindRemote,
137				},
138			})
139
140			require.Equal(t, tt.wantPresentation, sess.statusSegmentsFor(ac, true).session)
141			require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment")
142		})
143	}
144}
145
146func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
147	p1, releasePTY1 := newBlockingPTY(t)
148	p2, releasePTY2 := newBlockingPTY(t)
149	_, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
150	defer releasePTY1()
151	defer releasePTY2()
152
153	sess.tabs[1].name = "logs"
154
155	pane0 := sess.tabs[0].focusedPane()
156	pane0.mu.Lock()
157	pane0.title.processName = "vim"
158	pane0.title.processNameValid = true
159	pane0.mu.Unlock()
160
161	pane1 := sess.tabs[1].focusedPane()
162	pane1.mu.Lock()
163	pane1.title.terminalTitle = "build output"
164	pane1.mu.Unlock()
165
166	snap := sess.statusSegments(true)
167
168	require.Len(t, snap.tabs, 2)
169	require.Equal(t, "1", snap.tabs[0].name)
170	require.Equal(t, "vim", snap.tabs[0].paneTitle)
171	require.Equal(t, "logs", snap.tabs[1].name)
172	require.Equal(t, "build output", snap.tabs[1].paneTitle)
173}
174
175func TestStatusSegmentsOmitsTerminalTitleWhenDisabled(t *testing.T) {
176	p1, releasePTY1 := newBlockingPTY(t)
177	p2, releasePTY2 := newBlockingPTY(t)
178	_, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
179	defer releasePTY1()
180	defer releasePTY2()
181
182	sess.tabs[1].name = "logs"
183
184	pane0 := sess.tabs[0].focusedPane()
185	pane0.mu.Lock()
186	pane0.title.processName = "vim"
187	pane0.title.processNameValid = true
188	pane0.title.terminalTitle = "server.go — vev"
189	pane0.mu.Unlock()
190
191	pane1 := sess.tabs[1].focusedPane()
192	pane1.mu.Lock()
193	pane1.title.terminalTitle = "build output"
194	pane1.mu.Unlock()
195
196	snap := sess.statusSegments(false)
197
198	require.Len(t, snap.tabs, 2)
199	require.Equal(t, "1", snap.tabs[0].name)
200	require.Equal(t, "vim", snap.tabs[0].paneTitle, "OSC title must be omitted while process name still shows")
201	require.Equal(t, "logs", snap.tabs[1].name)
202	require.Equal(t, "sh", snap.tabs[1].paneTitle, "no process name and terminal title disabled falls back to the shell fallback")
203}
204
205func TestFitTabLabels(t *testing.T) {
206	tests := []struct {
207		name      string
208		tabs      []statusTab
209		rowLen    int
210		rightText string
211		want      []fittedTabLabel
212	}{
213		{
214			name:   "no tabs",
215			tabs:   nil,
216			rowLen: 10,
217			want:   []fittedTabLabel{},
218		},
219		{
220			name:   "everything fits stays untruncated",
221			tabs:   []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}},
222			rowLen: 40,
223			want:   []fittedTabLabel{{text: "one", nameLen: len("one")}, {text: "two (shell)", nameLen: len("two")}},
224		},
225		{
226			name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis",
227			tabs: []statusTab{
228				{name: "a"},
229				{name: "ed", paneTitle: "verylongtitle"},
230			},
231			rowLen: 20,
232			want:   []fittedTabLabel{{text: "a", nameLen: len("a")}, {text: "ed (verylongt…", nameLen: len("ed")}},
233		},
234		{
235			name:   "tight budget degrades to a truncated tab name only",
236			tabs:   []statusTab{{name: "editor", paneTitle: "some pane title text"}},
237			rowLen: 7,
238			want:   []fittedTabLabel{{text: "edi…", nameLen: len("edi…")}},
239		},
240		{
241			name:      "non-empty rightText shrinks the budget",
242			tabs:      []statusTab{{name: "aaaaaaaaaa"}},
243			rowLen:    20,
244			rightText: "0123456789",
245			want:      []fittedTabLabel{{text: "aaaaa…", nameLen: len("aaaaa…")}},
246		},
247		{
248			name:   "single tab wider than the whole row collapses to empty",
249			tabs:   []statusTab{{name: "reallyverylongtabname"}},
250			rowLen: 3,
251			want:   []fittedTabLabel{{}},
252		},
253	}
254	for _, tt := range tests {
255		t.Run(tt.name, func(t *testing.T) {
256			got := fitTabLabels(tt.tabs, tt.rowLen, tt.rightText)
257
258			require.Equal(t, tt.want, got)
259
260			reserve := 1
261			if tt.rightText != "" {
262				reserve = statusTextWidth(tt.rightText) + 2
263			}
264			budget := tt.rowLen - reserve
265			drawn := 0
266			for i, tb := range tt.tabs {
267				overhead := 2
268				if tb.attention {
269					overhead += 1 + renderer.RuneWidth(ui.AttentionGlyph)
270				}
271				drawn += overhead + statusTextWidth(got[i].text)
272				require.LessOrEqual(t, got[i].nameLen, len(got[i].text), "nameLen must not exceed the label's byte length")
273				if got[i].nameLen < len(got[i].text) {
274					require.True(t, utf8.RuneStart(got[i].text[got[i].nameLen]), "nameLen must land on a rune boundary")
275				}
276			}
277			if budget > 0 {
278				require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget")
279			}
280		})
281	}
282}
283
284func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
285	tabs := []statusTab{
286		{name: "api", paneTitle: "shell"},
287		{name: "logs", active: true, attention: true},
288		{name: "err", paneTitle: "build failed", attention: true},
289	}
290	status := statusSnapshot{tabs: tabs}
291	const rowLen = 60
292
293	t.Run("truecolor theme bolds names, mutes titles, and the bell keeps the base background", func(t *testing.T) {
294		theme := themeui.Theme{
295			Foreground: renderer.RGB{R: 200, G: 200, B: 200},
296			Background: renderer.RGB{R: 10, G: 20, B: 30},
297			HasFG:      true,
298			HasBG:      true,
299			Known:      true,
300		}
301		styles := themeui.NewStyles(theme)
302		require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
303		require.False(t, styles.TabTitleActive.Equal(styles.Accent), "sanity: muted active-title style must differ from the base accent style")
304
305		labels := fitTabLabels(tabs, rowLen, "")
306
307		// Frame 0 is the bell's blank beat: the attention space and the blank
308		// bell cell must both be exactly the tab's base style (not
309		// renderer.DefaultStyle(), which would punch a hole in a themed bar).
310		// This also pins down segment order: name, then (if attention)
311		// space+bell, then title, then the trailing space.
312		row := make([]renderer.Cell, rowLen)
313		drawTopBarSnapshot(row, status, 0, "", styles)
314		x := 0
315
316		// tab 0: inactive, no attention: name, title, trailing space.
317		nameEnd := x + 1 + labels[0].nameLen
318		for i := x; i < nameEnd; i++ {
319			require.True(t, row[i].Style.Bold, "cell %d: name segment must be bold", i)
320			require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: name segment style mismatch", i)
321		}
322		x = nameEnd
323		titleLen := len(labels[0].text) - labels[0].nameLen
324		titleEnd := x + titleLen
325		for i := x; i < titleEnd; i++ {
326			require.False(t, row[i].Style.Bold, "cell %d: title segment must not be bold", i)
327			require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: title segment style mismatch", i)
328		}
329		x = titleEnd
330		require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 0 trailing space must keep the base style", x)
331		x++
332
333		// tab 1: active, attention, no title: name, attention space+bell (both
334		// base style at frame 0), trailing space.
335		nameEnd = x + 1 + labels[1].nameLen
336		for i := x; i < nameEnd; i++ {
337			require.True(t, row[i].Style.Bold, "cell %d: active name segment must be bold", i)
338			require.True(t, row[i].Style.Equal(styles.TabNameActive), "cell %d: active name segment style mismatch", i)
339		}
340		require.Equal(t, len(labels[1].text), labels[1].nameLen, "tab 1 has no pane title, so its whole label is the name segment")
341		x = nameEnd
342		require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 attention leading space must keep the active base style", x)
343		x++
344		tab1Bell := x
345		require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 blank bell must keep the active base style, not DefaultStyle", x)
346		x++
347		require.True(t, row[x].Style.Equal(styles.Accent), "cell %d: tab 1 trailing space must keep the active base style", x)
348		x++
349
350		// tab 2: inactive, attention, with title: name, attention space+bell
351		// (both base style at frame 0), title, trailing space.
352		nameEnd = x + 1 + labels[2].nameLen
353		for i := x; i < nameEnd; i++ {
354			require.True(t, row[i].Style.Bold, "cell %d: tab 2 name segment must be bold", i)
355			require.True(t, row[i].Style.Equal(styles.TabName), "cell %d: tab 2 name segment style mismatch", i)
356		}
357		x = nameEnd
358		require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 attention leading space must keep the base style", x)
359		x++
360		tab2Bell := x
361		require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 blank bell must keep the base style, not DefaultStyle", x)
362		x++
363		titleLen = len(labels[2].text) - labels[2].nameLen
364		titleEnd = x + titleLen
365		for i := x; i < titleEnd; i++ {
366			require.False(t, row[i].Style.Bold, "cell %d: tab 2 title segment must not be bold", i)
367			require.True(t, row[i].Style.Equal(styles.TabTitle), "cell %d: tab 2 title segment style mismatch", i)
368		}
369		x = titleEnd
370		require.True(t, row[x].Style.Equal(styles.StatusBar), "cell %d: tab 2 trailing space must keep the base style", x)
371
372		// On a visible pulse frame, the bell glyph itself must keep the same
373		// background as its tab's base style.
374		visibleRow := make([]renderer.Cell, rowLen)
375		drawTopBarSnapshot(visibleRow, status, 1, "", styles)
376		require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab1Bell].Rune)
377		require.True(t, visibleRow[tab1Bell].Style.HasBackgroundRGB)
378		require.Equal(t, styles.Accent.BackgroundRGB, visibleRow[tab1Bell].Style.BackgroundRGB, "tab 1's visible bell must keep the active base background")
379		require.Equal(t, rune(ui.AttentionGlyph), visibleRow[tab2Bell].Rune)
380		require.True(t, visibleRow[tab2Bell].Style.HasBackgroundRGB)
381		require.Equal(t, styles.StatusBar.BackgroundRGB, visibleRow[tab2Bell].Style.BackgroundRGB, "tab 2's visible bell must keep the plain base background")
382	})
383
384	t.Run("non-usable theme matches the pre-change fallback styles", func(t *testing.T) {
385		styles := resolveStyles(nil)
386		require.True(t, styles.TabName.Equal(styles.StatusBar))
387		require.True(t, styles.TabTitle.Equal(styles.StatusBar))
388		require.True(t, styles.TabNameActive.Equal(styles.Accent))
389		require.True(t, styles.TabTitleActive.Equal(styles.Accent))
390
391		row := make([]renderer.Cell, rowLen)
392		drawTopBarSnapshot(row, status, 0, "", styles)
393
394		// " api (shell) " (tab 0) + " logs  " (tab 1: name, attention
395		// space+blank bell, trailing space) + " err  (build failed) " (tab 2:
396		// name, attention space+blank bell, title, trailing space), then blank
397		// padding to rowLen: byte-for-byte what the pre-change renderer
398		// produced, since every new style field collapses to statusBar/accent.
399		const tab0 = " api (shell) "
400		const tab1 = " logs   "
401		const tab2 = " err   (build failed) "
402		require.Equal(t, tab0+tab1+tab2+strings.Repeat(" ", rowLen-len(tab0)-len(tab1)-len(tab2)), rowText(row))
403
404		for i, c := range row[:len(tab0)] {
405			require.True(t, c.Style.Equal(styles.StatusBar), "cell %d should use the plain base style", i)
406		}
407		tab1Start := len(tab0)
408		for i := tab1Start; i < tab1Start+len(tab1); i++ {
409			require.True(t, row[i].Style.Equal(styles.Accent), "cell %d should use the plain accent style, including the blank bell cell", i)
410		}
411		tab2Start := tab1Start + len(tab1)
412		for i := tab2Start; i < tab2Start+len(tab2); i++ {
413			require.True(t, row[i].Style.Equal(styles.StatusBar), "cell %d should use the plain base style, including the blank bell cell", i)
414		}
415	})
416}
417
418func TestTabDisplayName(t *testing.T) {
419	tests := []struct {
420		name  string
421		tab   *tab
422		index int
423		want  string
424	}{
425		{name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
426		{name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
427	}
428	for _, tt := range tests {
429		t.Run(tt.name, func(t *testing.T) {
430			require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
431		})
432	}
433}
434
435func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
436	p, release := newBlockingPTY(t)
437	_, sess, ac, _ := newManualSessionWithPTYs(t, p)
438	defer release()
439	win := testAttachmentTab(sess)
440	win.focusedPane().screen.Resize(12, 2)
441	win.size = domain.Size{Cols: 12, Rows: 2}
442	ac.setThemeForTest(themeui.Theme{
443		Foreground: renderer.RGB{R: 220, G: 220, B: 220},
444		Background: renderer.RGB{R: 10, G: 20, B: 30},
445		HasFG:      true,
446		HasBG:      true,
447		Known:      true,
448	})
449
450	applied := ac.getAppliedTheme()
451	bars := barState{status: sess.statusSegments(true), theme: applied.Raw}
452	composed := composeFrame(capturedRenderState{
453		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme, styles: applied.Resolved.Styles, styleGeneration: applied.Generation,
454	}, composeCacheInput{})
455	out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
456
457	require.NoError(t, err)
458	require.Contains(t, string(out), ";48;2;")
459}
460
461func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
462	p1, releasePTY1 := newBlockingPTY(t)
463	p2, releasePTY2 := newBlockingPTY(t)
464	d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
465	defer releasePTY1()
466	defer releasePTY2()
467	msg := protocol.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, SchemeKnown: true, Light: true}
468
469	d.applyTheme(sess, ac, msg)
470
471	require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getAppliedTheme().Raw.Foreground)
472	require.True(t, ac.getAppliedTheme().Raw.SchemeKnown)
473	require.True(t, ac.getAppliedTheme().Raw.Light)
474	assertSessionDefaultColors(t, sess, renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6})
475	assertSessionColorScheme(t, sess, true)
476}
477
478func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
479	p, releasePTY := newBlockingPTY(t)
480	defer releasePTY()
481	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
482	tb := testAttachmentTab(sess)
483	floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
484	installTestFloating(tb, floating, true)
485	clientTheme := protocol.Theme{
486		HasForeground: true,
487		Foreground:    renderer.RGB{R: 1, G: 2, B: 3},
488		HasBackground: true,
489		Background:    renderer.RGB{R: 4, G: 5, B: 6},
490		SchemeKnown:   true,
491		Light:         true,
492	}
493
494	d.applyTheme(sess, ac, clientTheme)
495	assertPaneDefaultColors(t, floating, clientTheme.Foreground, clientTheme.Background)
496	assertPaneColorScheme(t, floating, true)
497
498	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
499	assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
500	assertPaneColorScheme(t, floating, false)
501}
502
503func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
504	tests := []struct {
505		name string
506		mode domain.ThemeMode
507		want themeui.Theme
508	}{
509		{name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
510		{name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
511	}
512	for _, tc := range tests {
513		t.Run(tc.name, func(t *testing.T) {
514			p1, releasePTY1 := newBlockingPTY(t)
515			p2, releasePTY2 := newBlockingPTY(t)
516			d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
517			defer releasePTY1()
518			defer releasePTY2()
519			d.ApplyConfig(domain.Config{Theme: tc.mode})
520
521			d.applyTheme(sess, ac, protocol.Theme{
522				HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
523				HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
524				SchemeKnown: true, Light: !tc.want.Light,
525			})
526
527			require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
528			assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
529			assertSessionColorScheme(t, sess, tc.want.Light)
530		})
531	}
532}
533
534func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
535	tests := []struct {
536		name string
537		mode domain.ThemeMode
538		want themeui.Theme
539	}{
540		{name: "dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
541		{name: "light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
542	}
543	for _, tc := range tests {
544		t.Run(tc.name, func(t *testing.T) {
545			p, releasePTY := newBlockingPTY(t)
546			d, sess, _, _ := newManualSessionWithPTYs(t, p)
547			defer releasePTY()
548			d.ApplyConfig(domain.Config{Theme: tc.mode})
549			for _, tb := range sess.tabs {
550				tb.mu.Lock()
551				panes := tb.panesSnapshot()
552				tb.mu.Unlock()
553				for _, p := range panes {
554					p.mu.Lock()
555					p.screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
556					p.mu.Unlock()
557				}
558			}
559			tr, _ := newCapturingTransport(t)
560
561			ac, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
562			require.NoError(t, err)
563
564			require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
565			assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
566			assertSessionColorScheme(t, sess, tc.want.Light)
567		})
568	}
569}
570
571func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
572	p, releasePTY := newBlockingPTY(t)
573	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
574	defer releasePTY()
575	d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto})
576	d.applyTheme(sess, ac, protocol.Theme{
577		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
578		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
579		SchemeKnown: true, Light: true,
580	})
581	assertSessionColorScheme(t, sess, true)
582
583	d.clientGone(sess, ac, ac.transport(), true)
584
585	assertSessionColorSchemeUnknown(t, sess)
586}
587
588func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
589	p, releasePTY := newBlockingPTY(t)
590	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
591	defer releasePTY()
592	d.ApplyConfig(domain.Config{Theme: domain.ThemeAuto})
593	d.applyTheme(sess, ac, protocol.Theme{
594		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
595		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
596		SchemeKnown: true, Light: true,
597	})
598	assertSessionColorScheme(t, sess, true)
599
600	tr, _ := newCapturingTransport(t)
601	_, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
602	require.NoError(t, err)
603
604	assertSessionColorSchemeUnknown(t, sess)
605}
606
607func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
608	p, releasePTY := newBlockingPTY(t)
609	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
610	defer releasePTY()
611	d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
612	d.applyTheme(sess, ac, protocol.Theme{
613		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
614		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
615		SchemeKnown: true, Light: true,
616	})
617
618	d.clientGone(sess, ac, ac.transport(), true)
619
620	assertSessionDefaultColors(t, sess, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
621	assertSessionColorScheme(t, sess, false)
622}
623
624func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
625	p, releasePTY := newBlockingPTY(t)
626	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
627	defer releasePTY()
628
629	d.applyTheme(sess, ac, protocol.Theme{
630		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
631		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
632		SchemeKnown: true, Light: true,
633	})
634	d.applyTheme(sess, ac, protocol.Theme{
635		HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
636		HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
637	})
638
639	assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
640	assertSessionColorScheme(t, sess, true)
641}
642
643func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
644	t.Helper()
645	for _, tb := range sess.tabs {
646		assertPaneDefaultColors(t, tb.focusedPane(), fg, bg)
647	}
648}
649
650func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
651	t.Helper()
652	var got []byte
653	p.mu.Lock()
654	p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
655	p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
656	p.mu.Unlock()
657	require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
658	require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
659}
660
661func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
662	t.Helper()
663	for _, tb := range sess.tabs {
664		assertPaneColorScheme(t, tb.focusedPane(), light)
665	}
666}
667
668func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
669	t.Helper()
670	want := "\x1b[?997;1n"
671	if light {
672		want = "\x1b[?997;2n"
673	}
674	var got []byte
675	p.mu.Lock()
676	p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
677	p.screen.Write([]byte("\x1b[?996n"))
678	p.mu.Unlock()
679	require.Equal(t, want, string(got))
680}
681
682func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
683	t.Helper()
684	for _, tb := range sess.tabs {
685		p := tb.focusedPane()
686		var got []byte
687		p.mu.Lock()
688		p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
689		p.screen.Write([]byte("\x1b[?996n"))
690		p.mu.Unlock()
691		require.Empty(t, got)
692	}
693}
694
695func formatOSCColor(rgb renderer.RGB) string {
696	return fmt.Sprintf("rgb:%02x%02x/%02x%02x/%02x%02x", rgb.R, rgb.R, rgb.G, rgb.G, rgb.B, rgb.B)
697}
698
699func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
700	p, release := newBlockingPTY(t)
701	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
702	defer release()
703	d.applyTheme(sess, ac, protocol.Theme{
704		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
705		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
706	})
707	tr, _ := newCapturingTransport(t)
708
709	_, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
710	require.NoError(t, err)
711
712	var got []byte
713	tb := testAttachmentTab(sess)
714	tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
715	tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
716	require.Empty(t, got)
717}
718
719func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
720	p, release := newBlockingPTY(t)
721	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
722	defer release()
723	d.applyTheme(sess, ac, protocol.Theme{
724		HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
725		HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
726	})
727
728	// Sanity: the tab answers OSC 10/11 while ac is attached.
729	tb := testAttachmentTab(sess)
730	var before []byte
731	tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
732	tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
733	require.NotEmpty(t, before)
734
735	d.clientGone(sess, ac, ac.transport(), true)
736
737	var got []byte
738	tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
739	tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
740	require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
741}
742
743func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
744	p, release := newBlockingPTY(t)
745	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
746	defer release()
747	d.applyTheme(sess, ac, protocol.Theme{
748		HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
749		HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
750	})
751
752	d.detachOnSendError(sess, ac, ac.transport())
753
754	tb := testAttachmentTab(sess)
755	var got []byte
756	tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
757	tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
758	require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
759}
760
761func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
762	p, release := newBlockingPTY(t)
763	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
764	defer release()
765	ac.resumeCapable = true
766	d.applyTheme(sess, ac, protocol.Theme{
767		HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
768		HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
769	})
770
771	d.detachOnSendError(sess, ac, ac.transport())
772
773	tb := testAttachmentTab(sess)
774	var got []byte
775	tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
776	tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
777	require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
778}
779
780func TestStatusMarksEphemeralSession(t *testing.T) {
781	p, release := newBlockingPTY(t)
782	_, sess, _, _ := newManualSessionWithPTYs(t, p)
783	defer release()
784	sess.name = "0"
785	sess.ephemeral = true
786	win := testAttachmentTab(sess)
787	win.focusedPane().screen.Resize(12, 2)
788	win.size = domain.Size{Cols: 12, Rows: 2}
789
790	frame := composeFrame(capturedRenderState{
791		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true)},
792	}, composeCacheInput{}).frame
793
794	require.Equal(t, " 1 (sh)     ", rowText(frame.Row(0)))
795	require.Equal(t, " 0*         ", rowText(frame.Row(3)))
796}
797
798func TestTopBarRightAnchor(t *testing.T) {
799	tests := []struct {
800		name             string
801		width            int
802		status           statusSnapshot
803		topRight         string
804		want             string
805		continuationCell int
806	}{
807		{
808			name:     "renders flush right when fully fits",
809			width:    20,
810			status:   statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
811			topRight: "14:32",
812			want:     " 1             14:32",
813		},
814		{
815			// The tab budget reserves room for topRight up front (fitTabLabels),
816			// so tab labels shrink to nothing before the right-anchored text is
817			// dropped; unlike the bottom bar's MRU, top-bar tabs never hide
818			// entirely, so topRight still renders once tabs are squeezed out.
819			name:     "squeezes tabs empty to keep topRight visible",
820			width:    10,
821			status:   statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
822			topRight: "12345",
823			want:     "     12345",
824		},
825		{
826			name:             "uses display width",
827			width:            12,
828			status:           statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
829			topRight:         "界a",
830			want:             " 1       界 a",
831			continuationCell: 10,
832		},
833	}
834	for _, tt := range tests {
835		t.Run(tt.name, func(t *testing.T) {
836			row := make([]renderer.Cell, tt.width)
837
838			drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveStyles(nil))
839
840			require.Equal(t, tt.want, rowText(row))
841			if tt.continuationCell > 0 {
842				require.True(t, row[tt.continuationCell].Continuation)
843			}
844		})
845	}
846}
847
848func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
849	p, releasePTY := newBlockingPTY(t)
850	_, sess, _, _ := newManualSessionWithPTYs(t, p)
851	defer releasePTY()
852	sess.name = "work"
853	win := testAttachmentTab(sess)
854	win.focusedPane().screen.Resize(30, 2)
855	win.size = domain.Size{Cols: 30, Rows: 2}
856
857	frame := composeFrame(capturedRenderState{
858		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "ok"},
859	}, composeCacheInput{}).frame
860	require.Equal(t, " work                       ok", rowText(frame.Row(3)))
861
862	frame = composeFrame(capturedRenderState{
863		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "1234567890123456789"},
864	}, composeCacheInput{}).frame
865	require.Equal(t, " work      1234567890123456789", rowText(frame.Row(3)))
866
867	frame = composeFrame(capturedRenderState{
868		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), statusFeedback: "selection too large to copy"},
869	}, composeCacheInput{}).frame
870	require.Equal(t, " work                         ", rowText(frame.Row(3)))
871}
872
873func TestAttentionConstants(t *testing.T) {
874	require.Equal(t, '', ui.AttentionGlyph)
875	require.Equal(t, 30, pulseFrameCount)
876	require.Equal(t, 120*time.Millisecond, pulseFrameInterval)
877}
878
879func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
880	rgbBase := renderer.Style{
881		HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 200, G: 200, B: 200},
882		HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30},
883	}
884	indexedBase := renderer.Style{Foreground: -1, Background: -1, Inverse: true}
885
886	t.Run("invisible frame returns base unchanged", func(t *testing.T) {
887		for _, base := range []renderer.Style{rgbBase, indexedBase} {
888			style, visible := pulseStyleFor(0, base, false)
889			require.False(t, visible)
890			require.True(t, style.Equal(base))
891		}
892	})
893
894	t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
895		low, visible := pulseStyleFor(1, rgbBase, false)
896		require.True(t, visible)
897		require.True(t, low.Bold)
898		require.True(t, low.HasBackgroundRGB)
899		require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
900
901		peak, visible := pulseStyleFor(pulseFrameCount/2, rgbBase, false)
902		require.True(t, visible)
903		require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
904		require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
905	})
906
907	t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
908		low, visible := pulseStyleFor(1, indexedBase, false)
909		require.True(t, visible)
910		require.True(t, low.Bold)
911		require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
912
913		peak, visible := pulseStyleFor(pulseFrameCount/2, indexedBase, false)
914		require.True(t, visible)
915		require.Greater(t, peak.Foreground, low.Foreground)
916	})
917}
918
919func TestTopBarRendersAttentionBell(t *testing.T) {
920	p1, releasePTY1 := newBlockingPTY(t)
921	p2, releasePTY2 := newBlockingPTY(t)
922	_, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
923	defer releasePTY1()
924	defer releasePTY2()
925	win := testAttachmentTab(sess)
926	win.focusedPane().screen.Resize(18, 2)
927	win.size = domain.Size{Cols: 18, Rows: 2}
928	sess.mu.Lock()
929	sess.tabs[1].attention = true
930	sess.tabs[1].attentionAt = time.Unix(10, 0)
931	sess.mu.Unlock()
932
933	frame := composeFrame(capturedRenderState{
934		reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 18, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), attentionFrame: 1},
935	}, composeCacheInput{}).frame
936
937	// Tab 2's label is enriched with its focused pane's title ("sh", the
938	// default shell fallback) and truncated to fit alongside the bell. The
939	// bell sits right after the name, before the (title) segment.
940	require.Contains(t, rowText(frame.Row(0)), "2 "+string(ui.AttentionGlyph)+" (s")
941	for _, c := range frame.Row(0) {
942		if c.Rune == ui.AttentionGlyph {
943			require.True(t, c.Style.Bold)
944			return
945		}
946	}
947	t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
948}
949
950func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
951	var nilEntry *session
952	var typedNil *session
953	valid := &session{sessionCore: sessionCore{id: "valid"}}
954
955	for _, tc := range []struct {
956		name string
957		a    *session
958		b    *session
959	}{
960		{name: "nil interfaces", a: nilEntry, b: nilEntry},
961		{name: "typed nil", a: typedNil, b: valid},
962	} {
963		t.Run(tc.name, func(t *testing.T) {
964			require.NotPanics(t, func() {
965				unlock := lockAttachmentSessions(tc.a, tc.b)
966				unlock()
967			})
968		})
969	}
970}
971
972func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
973	d := newTestDaemon(t, nil, stubClock{})
974	var sess *session
975
976	require.NotPanics(t, func() {
977		state := d.barStateForPaletteHints(sess, "feedback", nil)
978		require.Equal(t, "feedback", state.statusFeedback)
979		require.Empty(t, state.status.session)
980	})
981}
982
983func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) {
984	p, releasePTY := newBlockingPTY(t)
985	d, sess, ac, _ := newManualSessionWithPTYs(t, p)
986	defer releasePTY()
987
988	snapshot := protocol.RecentRouteSnapshot{
989		Generation: 1,
990		Entries:    []protocol.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: protocol.RouteKindLocal, Attention: true, Visited: true}},
991	}
992	hints := palette.ContextualHints{
993		Kind:         command.ContextHintRecentSessions,
994		SelectedRank: 1,
995		Recent:       []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
996	}
997	// Hold the registry lock: contextual composition must use the immutable
998	// attachment snapshot and never consult daemon session history.
999	var contextual barState
1000	done := make(chan struct{})
1001	d.mu.Lock()
1002	go func() {
1003		contextual = d.barStateForAttachmentPaletteHintsFor(sess, ac, "", &hints, snapshot)
1004		close(done)
1005	}()
1006	select {
1007	case <-done:
1008	case <-time.After(time.Second):
1009		d.mu.Unlock()
1010		t.Fatal("contextual composition consulted the live session registry")
1011	}
1012	d.mu.Unlock()
1013
1014	require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
1015	require.Equal(t, []rankedRecent{{rank: 1, name: "captured", attention: true, selected: true}}, contextual.rankedRecent)
1016}
1017
1018func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
1019	_, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1020	capture := func(bars barState) capturedRenderState {
1021		t.Helper()
1022		state, ok := captureRenderState(sess, ac, renderCaptureRequest{
1023			bars:        bars,
1024			overlays:    capturedOverlayRenderState{},
1025			floatingCfg: domain.FloatingConfig{},
1026			reset:       false,
1027			lease:       nil,
1028		})
1029		require.True(t, ok)
1030		return *state
1031	}
1032	draw := func(state capturedRenderState) string {
1033		t.Helper()
1034		row := make([]renderer.Cell, 32)
1035		drawStatusBarState(row, state.bars, resolveStyles(nil))
1036		return rowText(row)
1037	}
1038	normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "vty"}, {name: "misc"}}}
1039	ac.sendMu.Lock()
1040	defer ac.sendMu.Unlock()
1041
1042	// An empty but non-nil list is JRS contextual mode, not normal mode.
1043	emptyContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{}})
1044	require.NotNil(t, emptyContextual.bars.rankedRecent)
1045	require.Empty(t, emptyContextual.bars.rankedRecent)
1046	emptyRow := draw(emptyContextual)
1047	require.NotContains(t, emptyRow, "vty")
1048	require.NotContains(t, emptyRow, "misc")
1049
1050	// A later normal frame must clear contextual mode even after scratch reuse.
1051	normalAfterEmpty := capture(normal)
1052	require.Nil(t, normalAfterEmpty.bars.rankedRecent)
1053	normalRow := draw(normalAfterEmpty)
1054	require.Contains(t, normalRow, "vty")
1055	require.Contains(t, normalRow, "misc")
1056
1057	// A populated contextual list reuses the same scratch and remains distinct.
1058	populatedContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{{rank: 1, name: "jrs"}}})
1059	require.NotNil(t, populatedContextual.bars.rankedRecent)
1060	require.Equal(t, []rankedRecent{{rank: 1, name: "jrs"}}, populatedContextual.bars.rankedRecent)
1061	populatedRow := draw(populatedContextual)
1062	require.Contains(t, populatedRow, "1:jrs")
1063	require.NotContains(t, populatedRow, "vty")
1064
1065	normalAfterPopulated := capture(normal)
1066	require.Nil(t, normalAfterPopulated.bars.rankedRecent)
1067	require.Contains(t, draw(normalAfterPopulated), "vty")
1068}
1069
1070func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1071	state := barState{
1072		status:         statusSnapshot{session: "cur"},
1073		attentionFrame: 1,
1074		mru: []recentRouteDisplay{
1075			{name: "fresh"},
1076			{name: "tmp", attention: true},
1077		},
1078	}
1079	row := make([]renderer.Cell, 24)
1080
1081	drawStatusBarState(row, state, resolveStyles(nil))
1082
1083	require.Equal(t, " cur  fresh  tmp       ", rowText(row))
1084	for _, c := range row {
1085		if c.Rune == ui.AttentionGlyph {
1086			require.True(t, c.Style.Bold)
1087			return
1088		}
1089	}
1090	t.Fatalf("inline bell not rendered: %q", rowText(row))
1091}
1092
1093func TestStatusBarShowsMRURanksWhileCtrlHeld(t *testing.T) {
1094	mru := []recentRouteDisplay{{name: "local"}, {name: "neferwl@arch"}, {name: "old"}}
1095	var tenEntries []recentRouteDisplay
1096	for _, name := range "abcdefghij" {
1097		tenEntries = append(tenEntries, recentRouteDisplay{name: string(name)})
1098	}
1099	tests := []struct {
1100		name      string
1101		mru       []recentRouteDisplay
1102		showRanks bool
1103		width     int
1104		want      string
1105	}{
1106		{name: "names only", width: 44, want: " cur  local  neferwl@arch  old "},
1107		{name: "ranks while held", showRanks: true, width: 50, want: " cur  1:local  2:neferwl@arch  3:old "},
1108		{name: "rank widths drop whole oldest entries", showRanks: true, width: 44, want: " cur  1:local  2:neferwl@arch "},
1109		{name: "only ctrl digits get ranks", showRanks: true, width: 100, mru: tenEntries, want: " cur  1:a  2:b  3:c  4:d  5:e  6:f  7:g  8:h  9:i  j "},
1110	}
1111	for _, tt := range tests {
1112		t.Run(tt.name, func(t *testing.T) {
1113			entries := mru
1114			if tt.mru != nil {
1115				entries = tt.mru
1116			}
1117			state := barState{status: statusSnapshot{session: "cur"}, mru: entries, showRanks: tt.showRanks}
1118			row := make([]renderer.Cell, tt.width)
1119
1120			drawStatusBarState(row, state, resolveStyles(nil))
1121
1122			require.Equal(t, tt.want, strings.TrimRight(rowText(row), " ")+" ")
1123		})
1124	}
1125}
1126
1127func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1128	theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}
1129	styles := themeui.NewStyles(theme)
1130	row := make([]renderer.Cell, 16)
1131
1132	drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
1133
1134	for _, idx := range []int{0, 1, 2, 3, 4} {
1135		require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx)
1136	}
1137	require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB)
1138}
1139
1140func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1141	styles := resolveStyles(nil)
1142	row := make([]renderer.Cell, 18)
1143	state := barState{
1144		status: statusSnapshot{session: "cur"},
1145		rankedRecent: []rankedRecent{
1146			{rank: 1, name: "wide界"},
1147			{rank: 2, name: "two", selected: true},
1148			{rank: 3, name: "three"},
1149		},
1150	}
1151
1152	drawStatusBarState(row, state, styles)
1153	text := rowText(row)
1154	require.Contains(t, text, "1:wide界")
1155	require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name.
1156	require.NotContains(t, text, "3:three")
1157	// A rank that does fit is accent-colored and its prefix is bold.
1158	row = make([]renderer.Cell, 28)
1159	drawStatusBarState(row, state, styles)
1160	require.Contains(t, rowText(row), "2:two")
1161	found := false
1162	for _, cell := range row {
1163		if cell.Rune == '2' {
1164			found = true
1165			require.True(t, cell.Style.Bold)
1166			require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground)
1167			require.Equal(t, styles.Accent.Background, cell.Style.Background)
1168			break
1169		}
1170	}
1171	require.True(t, found)
1172}
1173
1174func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1175	theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
1176	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1177	theme.Palette[10] = theme.Palette[2]
1178	theme.PaletteKnown = 1<<2 | 1<<10
1179	resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1180	state := barState{status: statusSnapshot{session: "c"}, mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1181	row := make([]renderer.Cell, 16)
1182
1183	drawStatusBarState(row, state, resolved.Styles)
1184
1185	for index, cell := range []int{4, 7, 10} {
1186		require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)))
1187	}
1188	require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar")
1189
1190	plain := resolveStyles(nil).MRUStyle(1, 3)
1191	require.False(t, plain.HasForegroundRGB)
1192	require.False(t, plain.HasBackgroundRGB)
1193}
1194
1195func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1196	state := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1197	row := make([]renderer.Cell, 21)
1198
1199	drawStatusBarState(row, state, resolveStyles(nil))
1200
1201	text := rowText(row)
1202	require.Contains(t, text, "fresh")
1203	require.Contains(t, text, "middle")
1204	require.NotContains(t, text, "old")
1205}
1206
1207func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1208	mru := make([]recentRouteDisplay, 0, maxMRUSessions)
1209	for i := 1; i <= maxMRUSessions; i++ {
1210		mru = append(mru, recentRouteDisplay{name: "recent" + strconv.Itoa(i)})
1211	}
1212	state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
1213
1214	tests := []struct {
1215		name       string
1216		cols       int
1217		wantShown  []string
1218		wantHidden []string
1219	}{
1220		{
1221			name:       "narrow keeps first recent when it physically fits",
1222			cols:       15,
1223			wantShown:  []string{"cur", "recent1"},
1224			wantHidden: []string{"recent2"},
1225		},
1226		{
1227			name:       "medium reserves right-side room instead of filling all recents",
1228			cols:       80,
1229			wantShown:  []string{"recent1", "recent6"},
1230			wantHidden: []string{"recent7", "recent9"},
1231		},
1232		{
1233			name:       "wide shows more recents while still keeping a right-side reserve",
1234			cols:       120,
1235			wantShown:  []string{"recent1", "recent9"},
1236			wantHidden: nil,
1237		},
1238	}
1239	for _, tt := range tests {
1240		t.Run(tt.name, func(t *testing.T) {
1241			row := make([]renderer.Cell, tt.cols)
1242
1243			drawStatusBarState(row, state, resolveStyles(nil))
1244
1245			text := rowText(row)
1246			for _, want := range tt.wantShown {
1247				require.Contains(t, text, want)
1248			}
1249			for _, hidden := range tt.wantHidden {
1250				require.NotContains(t, text, hidden)
1251			}
1252		})
1253	}
1254}
1255
1256func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1257	tests := []struct {
1258		name              string
1259		width             int
1260		state             barState
1261		want              string
1262		wantContains      []string
1263		wantNotContains   []string
1264		wantSuffix        string
1265		continuationCells []int
1266	}{
1267		{
1268			name:         "script text and copy feedback",
1269			width:        32,
1270			state:        barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}}},
1271			wantContains: []string{" a"},
1272			wantSuffix:   " main ↑3 * copied",
1273		},
1274		{
1275			name:            "hide on overlap",
1276			width:           16,
1277			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1278			want:            " cur            ",
1279			wantNotContains: []string{"main", "copied"},
1280		},
1281		{
1282			name:  "empty script keeps copy feedback behavior",
1283			width: 12,
1284			state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1285			want:  " cur  copied",
1286		},
1287		{
1288			name:            "mru whole entry fitting with script text",
1289			width:           24,
1290			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
1291			wantContains:    []string{" fresh"},
1292			wantNotContains: []string{"middle"},
1293			wantSuffix:      " git",
1294		},
1295		{
1296			name:              "mru fitting reserves wide right anchor width",
1297			width:             12,
1298			state:             barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentRouteDisplay{{name: "a"}}},
1299			wantNotContains:   []string{" a "},
1300			wantSuffix:        " 界 界 ",
1301			continuationCells: []int{9, 11},
1302		},
1303		{
1304			name:            "mru fitting counts wide session names",
1305			width:           20,
1306			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "界"}, {name: "界"}, {name: "b"}}},
1307			wantContains:    []string{" 界 "},
1308			wantNotContains: []string{" b "},
1309			wantSuffix:      " git",
1310		},
1311	}
1312	for _, tt := range tests {
1313		t.Run(tt.name, func(t *testing.T) {
1314			row := make([]renderer.Cell, tt.width)
1315
1316			drawStatusBarState(row, tt.state, resolveStyles(nil))
1317
1318			text := rowText(row)
1319			if tt.want != "" {
1320				require.Equal(t, tt.want, text)
1321			}
1322			for _, want := range tt.wantContains {
1323				require.Contains(t, text, want)
1324			}
1325			for _, hidden := range tt.wantNotContains {
1326				require.NotContains(t, text, hidden)
1327			}
1328			if tt.wantSuffix != "" {
1329				require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
1330			}
1331			for _, cell := range tt.continuationCells {
1332				require.True(t, row[cell].Continuation)
1333			}
1334		})
1335	}
1336}
1337
1338func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1339	state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1340	row := make([]renderer.Cell, 20)
1341
1342	drawStatusBarState(row, state, resolveStyles(nil))
1343
1344	text := rowText(row)
1345	require.Contains(t, text, " a")
1346	require.Contains(t, text, " b")
1347	require.Contains(t, text, " c")
1348	require.True(t, strings.HasSuffix(text, " copied"), text)
1349}
1350
1351func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1352	state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}
1353	tests := []struct {
1354		name string
1355		cols int
1356		want string
1357	}{
1358		{name: "just wide enough for feedback after dropping MRU", cols: 12, want: " cur  copied"},
1359		{name: "too narrow drops feedback instead of clipping", cols: 11, want: " cur       "},
1360	}
1361	for _, tt := range tests {
1362		t.Run(tt.name, func(t *testing.T) {
1363			row := make([]renderer.Cell, tt.cols)
1364
1365			drawStatusBarState(row, state, resolveStyles(nil))
1366
1367			require.Equal(t, tt.want, rowText(row))
1368		})
1369	}
1370}
1371
1372func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1373	theme := themeui.Theme{
1374		Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
1375		Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1376		HasFG:      true,
1377		HasBG:      true,
1378		Known:      true,
1379		UsePalette: true,
1380	}
1381	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1382	theme.Palette[10] = theme.Palette[2]
1383	theme.PaletteKnown = 1<<2 | 1<<10
1384	resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1385	styles := resolved.Styles
1386
1387	t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) {
1388		row := make([]renderer.Cell, 48)
1389		status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}}
1390		drawTopBarSnapshot(row, status, 0, "clock", styles)
1391
1392		for _, cell := range row[28:40] {
1393			require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar")
1394		}
1395		require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive")
1396		require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text")
1397		selected := strings.Index(rowText(row), "selected")
1398		require.GreaterOrEqual(t, selected, 0)
1399		require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling")
1400		require.True(t, row[selected].Style.Bold, "active tab stays bold")
1401	})
1402
1403	t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) {
1404		row := make([]renderer.Cell, 48)
1405		state := barState{
1406			status:      statusSnapshot{session: "current"},
1407			bottomRight: "script ok",
1408			mru:         []recentRouteDisplay{{name: "new"}, {name: "middle"}, {name: "old"}},
1409		}
1410		drawStatusBarState(row, state, styles)
1411		for i := range len(" current ") {
1412			require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive")
1413		}
1414		for index, name := range []string{"new", "middle", "old"} {
1415			at := strings.Index(rowText(row), name)
1416			require.GreaterOrEqual(t, at, 0)
1417			require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name)
1418		}
1419		for _, cell := range row[30:38] {
1420			require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar")
1421		}
1422		at := strings.Index(rowText(row), "script ok")
1423		require.GreaterOrEqual(t, at, 0)
1424		require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar")
1425
1426		ranked := make([]renderer.Cell, 48)
1427		drawStatusBarState(ranked, barState{
1428			status:       statusSnapshot{session: "current"},
1429			rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}},
1430		}, styles)
1431		inactive := strings.Index(rowText(ranked), "inactive")
1432		chosen := strings.Index(rowText(ranked), "chosen")
1433		require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive")
1434		require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling")
1435	})
1436
1437	t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) {
1438		empty := make([]renderer.Cell, 20)
1439		drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles)
1440		for _, cell := range empty[len(" current "):] {
1441			require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar")
1442		}
1443
1444		single := make([]renderer.Cell, 20)
1445		drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)
1446		only := strings.Index(rowText(single), "only")
1447		require.GreaterOrEqual(t, only, 0)
1448		require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface")
1449	})
1450
1451	t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
1452		row := make([]renderer.Cell, 32)
1453		drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles)
1454		bells := make([]int, 0, 2)
1455		for i, cell := range row {
1456			if cell.Rune == ui.AttentionGlyph {
1457				bells = append(bells, i)
1458			}
1459		}
1460		require.Len(t, bells, 2)
1461		require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention")
1462		require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface")
1463	})
1464}
1465
1466func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1467	p1, releasePTY1 := newBlockingPTY(t)
1468	p2, releasePTY2 := newBlockingPTY(t)
1469	clock := newCoordinatorMockClock(t, 64)
1470	d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock.clock)
1471	tr, sends, releaseConn := newConn(t,
1472		mustHello(protocol.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
1473		frameInput([]byte("\x1b ")),
1474		frameInput([]byte("CNT\r")),
1475		frameInput([]byte("\x1b1")),
1476		mustMarshalResize(protocol.Resize{Size: domain.Size{Cols: 22, Rows: 6}}),
1477	)
1478
1479	var hg sync.WaitGroup
1480	hg.Go(func() { d.handleConn(tr) })
1481	awaitFrame(t, sends, "Welcome")
1482	first := awaitFrame(t, sends, "Output")
1483	require.Eventually(t, func() bool {
1484		sessions := listSessions(t, d)
1485		return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
1486	}, 2*time.Second, time.Millisecond)
1487
1488	// The queued create, switch, and resize transitions may collapse into one
1489	// latest-state wake. Advance every retained coordinator deadline
1490	// until that coalesced output is observable.
1491	resized := awaitCoordinatorOutput(
1492		t, sends, clock.timers,
1493		"while awaiting resized output",
1494		"timed out awaiting resized output frame",
1495	)
1496
1497	for _, f := range []wire.Envelope{first, resized} {
1498		out := unmarshalTestOutput(t, f.Payload)
1499		require.Contains(t, string(out.Data), "work")
1500		require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
1501	}
1502
1503	releaseConn()
1504	releasePTY1()
1505	releasePTY2()
1506	hg.Wait()
1507	d.sessWg.Wait()
1508	d.waitNotifies()
1509}

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