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 TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1094	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}
1095	styles := themeui.NewStyles(theme)
1096	row := make([]renderer.Cell, 16)
1097
1098	drawStatusBarState(row, barState{status: statusSnapshot{session: "cur"}, theme: theme}, styles)
1099
1100	for _, idx := range []int{0, 1, 2, 3, 4} {
1101		require.True(t, row[idx].Style.Equal(styles.Accent), "cell %d should use accent style", idx)
1102	}
1103	require.NotEqual(t, styles.StatusBar.BackgroundRGB, styles.Accent.BackgroundRGB)
1104}
1105
1106func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) {
1107	styles := resolveStyles(nil)
1108	row := make([]renderer.Cell, 18)
1109	state := barState{
1110		status: statusSnapshot{session: "cur"},
1111		rankedRecent: []rankedRecent{
1112			{rank: 1, name: "wide界"},
1113			{rank: 2, name: "two", selected: true},
1114			{rank: 3, name: "three"},
1115		},
1116	}
1117
1118	drawStatusBarState(row, state, styles)
1119	text := rowText(row)
1120	require.Contains(t, text, "1:wide界")
1121	require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name.
1122	require.NotContains(t, text, "3:three")
1123	// A rank that does fit is accent-colored and its prefix is bold.
1124	row = make([]renderer.Cell, 28)
1125	drawStatusBarState(row, state, styles)
1126	require.Contains(t, rowText(row), "2:two")
1127	found := false
1128	for _, cell := range row {
1129		if cell.Rune == '2' {
1130			found = true
1131			require.True(t, cell.Style.Bold)
1132			require.Equal(t, styles.Accent.Foreground, cell.Style.Foreground)
1133			require.Equal(t, styles.Accent.Background, cell.Style.Background)
1134			break
1135		}
1136	}
1137	require.True(t, found)
1138}
1139
1140func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1141	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}
1142	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1143	theme.Palette[10] = theme.Palette[2]
1144	theme.PaletteKnown = 1<<2 | 1<<10
1145	resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1146	state := barState{status: statusSnapshot{session: "c"}, mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1147	row := make([]renderer.Cell, 16)
1148
1149	drawStatusBarState(row, state, resolved.Styles)
1150
1151	for index, cell := range []int{4, 7, 10} {
1152		require.True(t, row[cell].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)))
1153	}
1154	require.NotEqual(t, resolved.Styles.SurfaceBar.BackgroundRGB, row[10].Style.BackgroundRGB, "oldest MRU remains distinct from the bar")
1155
1156	plain := resolveStyles(nil).MRUStyle(1, 3)
1157	require.False(t, plain.HasForegroundRGB)
1158	require.False(t, plain.HasBackgroundRGB)
1159}
1160
1161func TestStatusBarNarrowRowsDropWholeOldestMRUEntries(t *testing.T) {
1162	state := barState{status: statusSnapshot{session: "cur"}, mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
1163	row := make([]renderer.Cell, 21)
1164
1165	drawStatusBarState(row, state, resolveStyles(nil))
1166
1167	text := rowText(row)
1168	require.Contains(t, text, "fresh")
1169	require.Contains(t, text, "middle")
1170	require.NotContains(t, text, "old")
1171}
1172
1173func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
1174	mru := make([]recentRouteDisplay, 0, maxMRUSessions)
1175	for i := 1; i <= maxMRUSessions; i++ {
1176		mru = append(mru, recentRouteDisplay{name: "recent" + strconv.Itoa(i)})
1177	}
1178	state := barState{status: statusSnapshot{session: "cur"}, mru: mru}
1179
1180	tests := []struct {
1181		name       string
1182		cols       int
1183		wantShown  []string
1184		wantHidden []string
1185	}{
1186		{
1187			name:       "narrow keeps first recent when it physically fits",
1188			cols:       15,
1189			wantShown:  []string{"cur", "recent1"},
1190			wantHidden: []string{"recent2"},
1191		},
1192		{
1193			name:       "medium reserves right-side room instead of filling all recents",
1194			cols:       80,
1195			wantShown:  []string{"recent1", "recent6"},
1196			wantHidden: []string{"recent7", "recent9"},
1197		},
1198		{
1199			name:       "wide shows more recents while still keeping a right-side reserve",
1200			cols:       120,
1201			wantShown:  []string{"recent1", "recent9"},
1202			wantHidden: nil,
1203		},
1204	}
1205	for _, tt := range tests {
1206		t.Run(tt.name, func(t *testing.T) {
1207			row := make([]renderer.Cell, tt.cols)
1208
1209			drawStatusBarState(row, state, resolveStyles(nil))
1210
1211			text := rowText(row)
1212			for _, want := range tt.wantShown {
1213				require.Contains(t, text, want)
1214			}
1215			for _, hidden := range tt.wantHidden {
1216				require.NotContains(t, text, hidden)
1217			}
1218		})
1219	}
1220}
1221
1222func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
1223	tests := []struct {
1224		name              string
1225		width             int
1226		state             barState
1227		want              string
1228		wantContains      []string
1229		wantNotContains   []string
1230		wantSuffix        string
1231		continuationCells []int
1232	}{
1233		{
1234			name:         "script text and copy feedback",
1235			width:        32,
1236			state:        barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}}},
1237			wantContains: []string{" a"},
1238			wantSuffix:   " main ↑3 * copied",
1239		},
1240		{
1241			name:            "hide on overlap",
1242			width:           16,
1243			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1244			want:            " cur            ",
1245			wantNotContains: []string{"main", "copied"},
1246		},
1247		{
1248			name:  "empty script keeps copy feedback behavior",
1249			width: 12,
1250			state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}},
1251			want:  " cur  copied",
1252		},
1253		{
1254			name:            "mru whole entry fitting with script text",
1255			width:           24,
1256			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
1257			wantContains:    []string{" fresh"},
1258			wantNotContains: []string{"middle"},
1259			wantSuffix:      " git",
1260		},
1261		{
1262			name:              "mru fitting reserves wide right anchor width",
1263			width:             12,
1264			state:             barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []recentRouteDisplay{{name: "a"}}},
1265			wantNotContains:   []string{" a "},
1266			wantSuffix:        " 界 界 ",
1267			continuationCells: []int{9, 11},
1268		},
1269		{
1270			name:            "mru fitting counts wide session names",
1271			width:           20,
1272			state:           barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []recentRouteDisplay{{name: "界"}, {name: "界"}, {name: "b"}}},
1273			wantContains:    []string{" 界 "},
1274			wantNotContains: []string{" b "},
1275			wantSuffix:      " git",
1276		},
1277	}
1278	for _, tt := range tests {
1279		t.Run(tt.name, func(t *testing.T) {
1280			row := make([]renderer.Cell, tt.width)
1281
1282			drawStatusBarState(row, tt.state, resolveStyles(nil))
1283
1284			text := rowText(row)
1285			if tt.want != "" {
1286				require.Equal(t, tt.want, text)
1287			}
1288			for _, want := range tt.wantContains {
1289				require.Contains(t, text, want)
1290			}
1291			for _, hidden := range tt.wantNotContains {
1292				require.NotContains(t, text, hidden)
1293			}
1294			if tt.wantSuffix != "" {
1295				require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
1296			}
1297			for _, cell := range tt.continuationCells {
1298				require.True(t, row[cell].Continuation)
1299			}
1300		})
1301	}
1302}
1303
1304func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
1305	state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "a"}, {name: "b"}, {name: "c"}}}
1306	row := make([]renderer.Cell, 20)
1307
1308	drawStatusBarState(row, state, resolveStyles(nil))
1309
1310	text := rowText(row)
1311	require.Contains(t, text, " a")
1312	require.Contains(t, text, " b")
1313	require.Contains(t, text, " c")
1314	require.True(t, strings.HasSuffix(text, " copied"), text)
1315}
1316
1317func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1318	state := barState{status: statusSnapshot{session: "cur"}, statusFeedback: "copied", mru: []recentRouteDisplay{{name: "fresh"}}}
1319	tests := []struct {
1320		name string
1321		cols int
1322		want string
1323	}{
1324		{name: "just wide enough for feedback after dropping MRU", cols: 12, want: " cur  copied"},
1325		{name: "too narrow drops feedback instead of clipping", cols: 11, want: " cur       "},
1326	}
1327	for _, tt := range tests {
1328		t.Run(tt.name, func(t *testing.T) {
1329			row := make([]renderer.Cell, tt.cols)
1330
1331			drawStatusBarState(row, state, resolveStyles(nil))
1332
1333			require.Equal(t, tt.want, rowText(row))
1334		})
1335	}
1336}
1337
1338func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1339	theme := themeui.Theme{
1340		Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8},
1341		Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1342		HasFG:      true,
1343		HasBG:      true,
1344		Known:      true,
1345		UsePalette: true,
1346	}
1347	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1348	theme.Palette[10] = theme.Palette[2]
1349	theme.PaletteKnown = 1<<2 | 1<<10
1350	resolved := themeui.Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
1351	styles := resolved.Styles
1352
1353	t.Run("top bar maps inactive active and secondary roles while filling the row", func(t *testing.T) {
1354		row := make([]renderer.Cell, 48)
1355		status := statusSnapshot{tabs: []statusTab{{name: "idle", paneTitle: "shell"}, {name: "selected", active: true}}}
1356		drawTopBarSnapshot(row, status, 0, "clock", styles)
1357
1358		for _, cell := range row[28:40] {
1359			require.True(t, cell.Style.Equal(styles.SurfaceBar), "unowned top-bar filler must retain SurfaceBar")
1360		}
1361		require.True(t, row[1].Style.Equal(styles.TabInactive), "inactive name must use TabInactive")
1362		require.True(t, row[5].Style.Equal(styles.TabInactiveTitle), "pane title must use contrast-derived secondary text")
1363		selected := strings.Index(rowText(row), "selected")
1364		require.GreaterOrEqual(t, selected, 0)
1365		require.True(t, row[selected].Style.Equal(styles.TabActive), "active tab must outrank inactive styling")
1366		require.True(t, row[selected].Style.Bold, "active tab stays bold")
1367	})
1368
1369	t.Run("bottom bar maps current ranked and MRU roles without sacrificing right-side readability", func(t *testing.T) {
1370		row := make([]renderer.Cell, 48)
1371		state := barState{
1372			status:      statusSnapshot{session: "current"},
1373			bottomRight: "script ok",
1374			mru:         []recentRouteDisplay{{name: "new"}, {name: "middle"}, {name: "old"}},
1375		}
1376		drawStatusBarState(row, state, styles)
1377		for i := range len(" current ") {
1378			require.True(t, row[i].Style.Equal(styles.SurfaceActive), "current session must use SurfaceActive")
1379		}
1380		for index, name := range []string{"new", "middle", "old"} {
1381			at := strings.Index(rowText(row), name)
1382			require.GreaterOrEqual(t, at, 0)
1383			require.True(t, row[at].Style.Equal(themeui.MRUStyle(resolved.Ramp, index, 3)), "%s has the wrong MRU position surface", name)
1384		}
1385		for _, cell := range row[30:38] {
1386			require.True(t, cell.Style.Equal(styles.SurfaceBar), "bottom filler must retain SurfaceBar")
1387		}
1388		at := strings.Index(rowText(row), "script ok")
1389		require.GreaterOrEqual(t, at, 0)
1390		require.True(t, row[at].Style.Equal(styles.SurfaceBar), "right-side script text must remain readable on SurfaceBar")
1391
1392		ranked := make([]renderer.Cell, 48)
1393		drawStatusBarState(ranked, barState{
1394			status:       statusSnapshot{session: "current"},
1395			rankedRecent: []rankedRecent{{rank: 1, name: "inactive"}, {rank: 2, name: "chosen", selected: true}},
1396		}, styles)
1397		inactive := strings.Index(rowText(ranked), "inactive")
1398		chosen := strings.Index(rowText(ranked), "chosen")
1399		require.True(t, ranked[inactive].Style.Equal(styles.SurfaceInactive), "unselected ranked recent must use SurfaceInactive")
1400		require.True(t, ranked[chosen].Style.Equal(styles.SurfaceActive), "selected ranked recent must outrank inactive styling")
1401	})
1402
1403	t.Run("MRU zero and singleton cases retain semantic edge surfaces", func(t *testing.T) {
1404		empty := make([]renderer.Cell, 20)
1405		drawStatusBarState(empty, barState{status: statusSnapshot{session: "current"}}, styles)
1406		for _, cell := range empty[len(" current "):] {
1407			require.True(t, cell.Style.Equal(styles.SurfaceBar), "empty MRU leaves a complete SurfaceBar")
1408		}
1409
1410		single := make([]renderer.Cell, 20)
1411		drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles)
1412		only := strings.Index(rowText(single), "only")
1413		require.GreaterOrEqual(t, only, 0)
1414		require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface")
1415	})
1416
1417	t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) {
1418		row := make([]renderer.Cell, 32)
1419		drawTopBarSnapshot(row, statusSnapshot{tabs: []statusTab{{name: "active", active: true, attention: true}, {name: "idle", attention: true}}}, 1, "", styles)
1420		bells := make([]int, 0, 2)
1421		for i, cell := range row {
1422			if cell.Rune == ui.AttentionGlyph {
1423				bells = append(bells, i)
1424			}
1425		}
1426		require.Len(t, bells, 2)
1427		require.Equal(t, styles.SurfaceActive.BackgroundRGB, row[bells[0]].Style.BackgroundRGB, "active must outrank attention")
1428		require.Equal(t, styles.SurfaceInactive.BackgroundRGB, row[bells[1]].Style.BackgroundRGB, "attention must preserve inactive base surface")
1429	})
1430}
1431
1432func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1433	p1, releasePTY1 := newBlockingPTY(t)
1434	p2, releasePTY2 := newBlockingPTY(t)
1435	clock := newCoordinatorMockClock(t, 64)
1436	d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock.clock)
1437	tr, sends, releaseConn := newConn(t,
1438		mustHello(protocol.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
1439		frameInput([]byte("\x1b ")),
1440		frameInput([]byte("CNT\r")),
1441		frameInput([]byte("\x1b1")),
1442		mustMarshalResize(protocol.Resize{Size: domain.Size{Cols: 22, Rows: 6}}),
1443	)
1444
1445	var hg sync.WaitGroup
1446	hg.Go(func() { d.handleConn(tr) })
1447	awaitFrame(t, sends, "Welcome")
1448	first := awaitFrame(t, sends, "Output")
1449	require.Eventually(t, func() bool {
1450		sessions := listSessions(t, d)
1451		return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
1452	}, 2*time.Second, time.Millisecond)
1453
1454	// The queued create, switch, and resize transitions may collapse into one
1455	// latest-state wake. Advance every retained coordinator deadline
1456	// until that coalesced output is observable.
1457	resized := awaitCoordinatorOutput(
1458		t, sends, clock.timers,
1459		"while awaiting resized output",
1460		"timed out awaiting resized output frame",
1461	)
1462
1463	for _, f := range []wire.Envelope{first, resized} {
1464		out := unmarshalTestOutput(t, f.Payload)
1465		require.Contains(t, string(out.Data), "work")
1466		require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
1467	}
1468
1469	releaseConn()
1470	releasePTY1()
1471	releasePTY2()
1472	hg.Wait()
1473	d.sessWg.Wait()
1474	d.waitNotifies()
1475}

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