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