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}