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