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

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