v0.3.0 99dc266

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

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