1package palette
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/command"
12 themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/stretchr/testify/require"
14)
15
16func cnsCommand(t *testing.T) command.Command {
17 t.Helper()
18 for _, candidate := range command.PaletteRegistry() {
19 if candidate.Slug == "new-session" {
20 return candidate
21 }
22 }
23 require.FailNow(t, "new-session command missing from palette registry")
24 return command.Command{}
25}
26
27func TestCNSDestinationModeRequiresExplicitSelection(t *testing.T) {
28 cns := cnsCommand(t)
29 local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
30 remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
31
32 tests := []struct {
33 name string
34 query string
35 navigate func(*Model)
36 wantText string
37 wantSelect bool
38 wantFirst bool
39 }{
40 {name: "plain requires selection", query: "CNS", wantText: "Create session locally…", wantFirst: true},
41 {name: "named requires selection", query: "CNS example", wantText: "Create session “example”", wantFirst: true},
42 {name: "down chooses first", query: "CNS example", navigate: (*Model).Down, wantText: "Create session “example”", wantSelect: true, wantFirst: true},
43 {name: "up chooses last", query: "CNS example", navigate: (*Model).Up, wantText: "Create session “example” on host-a", wantSelect: true},
44 }
45 for _, tt := range tests {
46 t.Run(tt.name, func(t *testing.T) {
47 m := New([]Result{NewCommandResult(cns), local, remote})
48 for _, r := range tt.query {
49 m.Insert(r)
50 }
51 require.Len(t, m.Matches(), 2)
52 if tt.wantFirst {
53 require.Equal(t, tt.wantText, m.Matches()[0].Result.DisplayText())
54 }
55 if tt.navigate != nil {
56 tt.navigate(m)
57 }
58 selected, ok := m.Selected()
59 require.Equal(t, tt.wantSelect, ok)
60 if ok {
61 require.Equal(t, tt.wantText, selected.DisplayText())
62 }
63 })
64 }
65}
66
67func TestCNSDestinationRefreshPreservesOnlyExplicitSelection(t *testing.T) {
68 cns := cnsCommand(t)
69 local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
70 remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
71 m := New([]Result{NewCommandResult(cns), local, remote})
72 for _, r := range "CNS example" {
73 m.Insert(r)
74 }
75 m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
76 _, ok := m.Selected()
77 require.False(t, ok)
78
79 m.Down()
80 m.ReplaceResults([]Result{NewCommandResult(cns), remote, local})
81 selected, ok := m.Selected()
82 require.True(t, ok)
83 _, kind, _, _, _, ok := selected.CreateSessionDestination()
84 require.True(t, ok)
85 require.Equal(t, CreateSessionOnLocalRoute, kind)
86
87 remoteB := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-b", "host-b", protocol.RouteRef{}, 0)
88 m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
89 _, ok = m.Selected()
90 require.False(t, ok, "removing the explicit destination must not select its replacement")
91
92 m.Down()
93 m.ReplaceResults([]Result{NewCommandResult(cns), remote})
94 _, ok = m.Selected()
95 require.False(t, ok, "two-to-one refresh must not promote the generic command")
96
97 m.ReplaceResults([]Result{NewCommandResult(cns), remote, remoteB})
98 m.Down()
99 m.ReplaceResults([]Result{NewCommandResult(cns)})
100 _, ok = m.Selected()
101 require.False(t, ok, "two-to-zero refresh must not promote the generic command")
102}
103
104func TestCNSUnselectedDestinationCollapseDoesNotPromoteCommand(t *testing.T) {
105 cns := cnsCommand(t)
106 local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
107 remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
108 newModel := func() *Model {
109 m := New([]Result{NewCommandResult(cns), local, remote})
110 for _, r := range "CNS example" {
111 m.Insert(r)
112 }
113 return m
114 }
115
116 m := newModel()
117 m.ReplaceResults([]Result{NewCommandResult(cns), local})
118 _, ok := m.Selected()
119 require.False(t, ok, "unselected two-to-one refresh must not promote CNS")
120
121 m = newModel()
122 m.ReplaceResults([]Result{NewCommandResult(cns)})
123 _, ok = m.Selected()
124 require.False(t, ok, "unselected two-to-zero refresh must not promote CNS")
125}
126
127func TestCNSDestinationRefreshDoesNotPromoteCommandSelection(t *testing.T) {
128 cns := cnsCommand(t)
129 local := NewCreateSessionDestination(CreateSessionOnLocalRoute, "", "", protocol.RouteRef{Key: 1, Generation: 1}, 1)
130 remote := NewCreateSessionDestination(CreateSessionOnRemoteHost, "host-a", "host-a", protocol.RouteRef{}, 0)
131 m := New([]Result{NewCommandResult(cns), local})
132 for _, r := range "CNS" {
133 m.Insert(r)
134 }
135 _, ok := m.Selected()
136 require.True(t, ok, "single-daemon CNS keeps ordinary command selection")
137
138 m.ReplaceResults([]Result{NewCommandResult(cns), local, remote})
139 _, ok = m.Selected()
140 require.False(t, ok, "entering multi-destination mode requires fresh navigation")
141}
142
143// TestReplaceResultsPreservesExactSelection proves ReplaceResults tracks the
144// selected result by its exact target through a reorder, not by its position.
145func TestReplaceResultsPreservesExactSelection(t *testing.T) {
146 first := NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 1), time.Time{}, "user@arch")
147 selected := NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "admin@arch")
148 m := New([]Result{first, selected})
149 m.Down()
150
151 m.ReplaceResults([]Result{selected, first})
152
153 got, ok := m.Selected()
154 require.True(t, ok)
155 target, ok := got.SessionTarget()
156 require.True(t, ok)
157 require.Equal(t, domain.SessionLifecycleID{2}, target.LifecycleID)
158}
159
160func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
161 m := New(CommandResults([]command.Command{
162 cmd("ABC", "Alpha", "first"),
163 cmd("DEF", "Delta", "second"),
164 cmd("AXY", "Other", "third"),
165 }))
166
167 require.Equal(t, "", m.Query())
168 selected, ok := m.Selected()
169 require.True(t, ok)
170 require.Equal(t, "ABC", selectedCommandCode(t, selected))
171
172 m.Down()
173 m.Down()
174 selected, ok = m.Selected()
175 require.True(t, ok)
176 require.Equal(t, "AXY", selectedCommandCode(t, selected))
177
178 m.Insert('d')
179 require.Equal(t, "d", m.Query())
180 selected, ok = m.Selected()
181 require.True(t, ok)
182 require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
183
184 m.Up()
185 selected, ok = m.Selected()
186 require.True(t, ok)
187 require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
188
189 m.Backspace()
190 require.Equal(t, "", m.Query())
191 require.Len(t, m.Matches(), 3)
192}
193
194func TestModelSelectsExactSessionNameAheadOfPrefix(t *testing.T) {
195 m := New([]Result{
196 NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"),
197 NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"),
198 })
199 for _, r := range "vev" {
200 m.Insert(r)
201 }
202
203 selected, ok := m.Selected()
204 require.True(t, ok)
205 name, ok := selected.SessionName()
206 require.True(t, ok)
207 require.Equal(t, "vev", name)
208}
209
210func TestModelNoMatchesClearsSelection(t *testing.T) {
211 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
212 m.Insert('z')
213
214 _, ok := m.Selected()
215 require.False(t, ok)
216 require.Empty(t, m.Matches())
217}
218
219func TestModelMatchesDeepCopiesPositions(t *testing.T) {
220 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
221 m.Insert('a')
222
223 matches := m.Matches()
224 require.Len(t, matches, 1)
225 require.Equal(t, []int{0}, matches[0].Positions)
226
227 matches[0].Positions[0] = 2
228
229 fresh := m.Matches()
230 require.Equal(t, []int{0}, fresh[0].Positions)
231}
232
233func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
234 zzz := cmd("ZZZ", "", "ZZZ 1")
235 zzz.Arguments = command.ArgumentsRequired
236 commands := []command.Command{
237 cmd("AAA", "", "ZZZ 1"),
238 zzz,
239 }
240 want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
241 m := New(CommandResults(commands))
242
243 for _, r := range "ZZZ 1" {
244 m.Insert(r)
245 }
246
247 matches := m.Matches()
248 require.Len(t, matches, 2)
249 require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
250 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
251 selected, ok := m.Selected()
252 require.True(t, ok)
253 require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
254}
255
256func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
257 for _, tt := range []struct {
258 name string
259 commands func(zzz command.Command) []command.Command
260 want Match
261 wantLen int
262 }{
263 {
264 name: "absent fuzzy match is prepended",
265 commands: func(zzz command.Command) []command.Command {
266 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
267 },
268 want: newMatch(NewCommandResult(command.Command{
269 Code: "ZZZ",
270 Desc: "unmatched",
271 Arguments: command.ArgumentsRequired,
272 }), 0),
273 wantLen: 2,
274 },
275 {
276 name: "first fuzzy match is unchanged",
277 commands: func(zzz command.Command) []command.Command {
278 zzz.Desc = "ZZZ 1"
279 return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}
280 },
281 want: func() Match {
282 zzz := cmd("ZZZ", "", "ZZZ 1")
283 zzz.Arguments = command.ArgumentsRequired
284 return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
285 }(),
286 wantLen: 2,
287 },
288 } {
289 t.Run(tt.name, func(t *testing.T) {
290 zzz := cmd("ZZZ", "", "unmatched")
291 zzz.Arguments = command.ArgumentsRequired
292 m := New(CommandResults(tt.commands(zzz)))
293 for _, r := range "ZZZ 1" {
294 m.Insert(r)
295 }
296
297 matches := m.Matches()
298 require.Len(t, matches, tt.wantLen)
299 require.Equal(t, tt.want, matches[0])
300 })
301 }
302}
303
304func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
305 m := New(CommandResults([]command.Command{
306 cmd("CPY", "Copy", "Enter copy mode"),
307 cmd("CNT", "New", "Create tab"),
308 }))
309 m.Insert('c')
310 m.Insert('y')
311 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
312
313 require.Equal(t, '>', frame.At(0, 0).Rune)
314 require.Equal(t, ' ', frame.At(1, 0).Rune)
315 require.Equal(t, 'c', frame.At(2, 0).Rune)
316 require.Equal(t, 'y', frame.At(3, 0).Rune)
317 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
318
319 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
320 require.NotContains(t, frameRow(frame, 1), "Copy")
321 require.NotContains(t, frameRow(frame, 1), "—")
322 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
323 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
324 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
325 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
326 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
327 require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
328}
329
330func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
331 m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
332 want := []rune("CPY Enter copy mode")
333
334 for _, cols := range []int{0, 1, 3, 4, 7} {
335 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
336 frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
337 require.Equal(t, cols, frame.Width)
338 if cols > 0 {
339 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
340 }
341 })
342 }
343}
344
345func selectedCommandCode(t *testing.T, result Result) string {
346 t.Helper()
347 cmd, ok := result.Command()
348 require.True(t, ok, "selected result is a command")
349 return cmd.Code
350}
351
352func matchCommandCode(t *testing.T, match Match) string {
353 t.Helper()
354 return selectedCommandCode(t, match.Result)
355}
356
357func frameRow(frame renderer.Frame, y int) string {
358 row := make([]rune, frame.Width)
359 for x := range frame.Width {
360 row[x] = frame.At(x, y).Rune
361 }
362 return string(row)
363}
364
365func TestRenderUsesConfiguredStyles(t *testing.T) {
366 tests := []struct {
367 name string
368 styles func() RenderStyles
369 x, y int
370 unfiltered bool
371 assert func(t *testing.T, style renderer.Style)
372 }{
373 {
374 name: "selection style for fuzzy highlights",
375 styles: func() RenderStyles {
376 accent := renderer.DefaultStyle()
377 accent.HasBackgroundRGB = true
378 accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
379 styles := DefaultRenderStyles()
380 styles.Selection = accent
381 return styles
382 },
383 x: 0,
384 y: 1,
385 assert: func(t *testing.T, style renderer.Style) {
386 require.True(t, style.Bold)
387 require.False(t, style.Inverse)
388 require.True(t, style.HasBackgroundRGB)
389 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, style.BackgroundRGB)
390 },
391 },
392 {
393 name: "muted italic description style",
394 styles: func() RenderStyles {
395 muted := renderer.DefaultStyle()
396 muted.Italic = true
397 muted.HasForegroundRGB = true
398 muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30}
399 styles := DefaultRenderStyles()
400 styles.Description = muted
401 return styles
402 },
403 x: 4,
404 y: 2,
405 unfiltered: true,
406 assert: func(t *testing.T, style renderer.Style) {
407 require.True(t, style.Italic)
408 require.True(t, style.HasForegroundRGB)
409 require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized")
410 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
411 },
412 },
413 {
414 name: "description italic is configurable",
415 styles: func() RenderStyles {
416 styles := DefaultRenderStyles()
417 styles.Description.Italic = false
418 styles.SelectionDescription.Italic = false
419 return styles
420 },
421 x: 4,
422 y: 1,
423 assert: func(t *testing.T, style renderer.Style) {
424 require.False(t, style.Italic)
425 },
426 },
427 }
428
429 for _, tt := range tests {
430 t.Run(tt.name, func(t *testing.T) {
431 m := New(CommandResults([]command.Command{
432 cmd("CPY", "Copy", "Enter copy mode"),
433 cmd("CNT", "New", "Create tab"),
434 }))
435 if !tt.unfiltered {
436 m.Insert('c')
437 m.Insert('y')
438 }
439 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
440
441 tt.assert(t, frame.At(tt.x, tt.y).Style)
442 })
443 }
444}
445
446func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
447 selection := renderer.DefaultStyle()
448 selection.HasForegroundRGB = true
449 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
450 selection.HasBackgroundRGB = true
451 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
452 description := renderer.DefaultStyle()
453 description.Italic = true
454 description.HasForegroundRGB = true
455 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
456 selectionDescription := selection
457 selectionDescription.Italic = true
458 selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
459
460 model := New(CommandResults([]command.Command{
461 cmd("ONE", "One", "first description"),
462 cmd("TWO", "Two", "second description"),
463 }))
464 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
465 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
466 Selection: selection, Description: description, SelectionDescription: selectionDescription,
467 }})
468
469 selected := frame.At(4, 1).Style
470 require.True(t, selected.Equal(selectionDescription))
471
472 inactive := frame.At(4, 2).Style
473 require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
474 require.True(t, inactive.Italic)
475}
476
477func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
478 selection := renderer.DefaultStyle()
479 selection.HasForegroundRGB = true
480 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
481 selection.HasBackgroundRGB = true
482 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
483 description := renderer.DefaultStyle()
484 description.Italic = true
485 description.HasForegroundRGB = true
486 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
487
488 model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
489 frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
490 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
491 Selection: selection, Description: description,
492 }})
493
494 selected := frame.At(4, 1).Style
495 require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
496 require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
497 require.True(t, selected.Italic)
498}
499
500func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
501 paletteColors := [16]renderer.RGB{}
502 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
503 paletteColors[10] = paletteColors[2]
504 accentTheme := themeui.Theme{
505 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
506 HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
507 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
508 }
509 indexedTheme := accentTheme
510 indexedTheme.TrueColor = false
511 paletteOffTheme := accentTheme
512 paletteOffTheme.UsePalette = false
513 neutralTheme := paletteOffTheme
514 neutralTheme.PaletteKnown = 0
515
516 tests := []struct {
517 name string
518 theme themeui.Theme
519 policy domain.ThemeAccent
520 }{
521 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
522 {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
523 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
524 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
525 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
526 {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
527 }
528
529 for _, tt := range tests {
530 t.Run(tt.name, func(t *testing.T) {
531 styles := themeui.Resolve(tt.theme, tt.policy).Styles
532 model := New(CommandResults([]command.Command{
533 cmd("ONE", "One", "first description"),
534 cmd("TWO", "Two", "second description"),
535 }))
536 model.Down()
537 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
538 Base: styles.PickerBase, Row: styles.SurfaceInactive,
539 Selection: styles.PickerSelection, Description: styles.PickerDescription,
540 SelectionDescription: styles.PickerSelectionMuted,
541 }})
542 inactive := frame.At(4, 1).Style
543 require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
544 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
545 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
546
547 selected := frame.At(4, 2).Style
548 require.True(t, selected.Equal(styles.PickerSelectionMuted))
549 })
550 }
551}
552
553func TestModelCompleteSelected(t *testing.T) {
554 jrs := cmd("JRS", "Jump", "Jump to recent session")
555 jrs.Arguments = command.ArgumentsRequired
556 optional := cmd("CNS", "New session", "Create named session")
557 optional.Arguments = command.ArgumentsOptional
558 registryFirst := command.Registry()[0]
559
560 tests := []struct {
561 name string
562 commands []command.Command
563 registry bool
564 query string
565 down int
566 want string
567 wantChange bool
568 rendered string
569 }{
570 {name: "nil model", want: "", wantChange: false},
571 {name: "empty model", commands: nil, want: "", wantChange: false},
572 {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
573 {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
574 {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
575 {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
576 {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
577 {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
578 {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
579 {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
580 {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
581 {name: "leading whitespace optional argument does not duplicate token", commands: []command.Command{optional}, query: "\u2003CNS name", want: "\u2003CNS name", wantChange: false},
582 {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
583 {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
584 }
585
586 for _, tt := range tests {
587 t.Run(tt.name, func(t *testing.T) {
588 var m *Model
589 if tt.name != "nil model" {
590 if tt.registry {
591 m = NewRegistry()
592 } else {
593 m = New(CommandResults(tt.commands))
594 }
595 for _, r := range tt.query {
596 m.Insert(r)
597 }
598 for range tt.down {
599 m.Down()
600 }
601 }
602
603 changed := m.CompleteSelected()
604 require.Equal(t, tt.wantChange, changed)
605 if m == nil {
606 require.Equal(t, tt.want, "")
607 return
608 }
609 require.Equal(t, tt.want, m.Query())
610 if tt.rendered != "" {
611 frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
612 require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
613 }
614 })
615 }
616}
617
618func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
619 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
620 workTarget := testExactTarget("work", 1)
621 results := []Result{
622 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
623 NewActiveSessionResult(workTarget, created),
624 NewStoppedSessionResult(testExactTarget("work", 2), created),
625 }
626 m := New(results)
627 results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created)
628
629 for _, r := range "switch" {
630 m.Insert(r)
631 }
632 matches := m.Matches()
633 require.Len(t, matches, 1)
634 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
635 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
636 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
637
638 selected, ok := m.Selected()
639 require.True(t, ok)
640 require.Equal(t, ResultKindActiveSession, selected.Kind())
641 target, active := selected.SessionTarget()
642 require.True(t, active)
643 require.Equal(t, workTarget, target)
644 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
645 require.Equal(t, "switch", m.Query())
646 _, argument := m.ArgumentCommand()
647 require.False(t, argument)
648
649 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
650 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
651}
652
653func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
654 m := New([]Result{NewStoppedSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
655 m.Insert('w')
656 m.Insert('k')
657
658 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
659
660 require.Equal(t, "Resume session work ", frameRow(frame, 1))
661 require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
662 require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
663 require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
664}
665
666func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
667 m := New([]Result{NewActiveSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
668 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
669 require.Len(t, m.Matches(), 1)
670 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
671}
672
673func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
674 jrs := cmd("JRS", "Jump", "Jump to recent session")
675 jrs.Arguments = command.ArgumentsRequired
676 jrs.ContextHint = command.ContextHintRecentSessions
677 m := New(CommandResults([]command.Command{jrs}))
678 for _, r := range "JRS 1" {
679 m.Insert(r)
680 }
681
682 frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
683 if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
684 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
685 }
686}
687
688func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
689 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
690 base := renderer.Style{Foreground: 1, Background: 2}
691 row := renderer.Style{Foreground: 3, Background: 4}
692 selection := renderer.Style{Foreground: 5, Background: 6}
693 frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
694
695 require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
696 require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
697 require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
698}