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.False(t, frame.At(4, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn")
318 col, ok := m.Caret(domain.Size{Cols: 28, Rows: 3})
319 require.True(t, ok)
320 require.Equal(t, 4, col, "caret follows the query")
321
322 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
323 require.NotContains(t, frameRow(frame, 1), "Copy")
324 require.NotContains(t, frameRow(frame, 1), "—")
325 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
326 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
327 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
328 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
329 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
330 require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
331}
332
333func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
334 m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
335 want := []rune("CPY Enter copy mode")
336
337 for _, cols := range []int{0, 1, 3, 4, 7} {
338 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
339 frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
340 require.Equal(t, cols, frame.Width)
341 if cols > 0 {
342 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
343 }
344 })
345 }
346}
347
348func selectedCommandCode(t *testing.T, result Result) string {
349 t.Helper()
350 cmd, ok := result.Command()
351 require.True(t, ok, "selected result is a command")
352 return cmd.Code
353}
354
355func matchCommandCode(t *testing.T, match Match) string {
356 t.Helper()
357 return selectedCommandCode(t, match.Result)
358}
359
360func frameRow(frame renderer.Frame, y int) string {
361 row := make([]rune, frame.Width)
362 for x := range frame.Width {
363 row[x] = frame.At(x, y).Rune
364 }
365 return string(row)
366}
367
368func TestRenderUsesConfiguredStyles(t *testing.T) {
369 tests := []struct {
370 name string
371 styles func() RenderStyles
372 x, y int
373 unfiltered bool
374 assert func(t *testing.T, style renderer.Style)
375 }{
376 {
377 name: "selection style for fuzzy highlights",
378 styles: func() RenderStyles {
379 accent := renderer.DefaultStyle()
380 accent.HasBackgroundRGB = true
381 accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
382 styles := DefaultRenderStyles()
383 styles.Selection = accent
384 return styles
385 },
386 x: 0,
387 y: 1,
388 assert: func(t *testing.T, style renderer.Style) {
389 require.True(t, style.Bold)
390 require.False(t, style.Inverse)
391 require.True(t, style.HasBackgroundRGB)
392 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, style.BackgroundRGB)
393 },
394 },
395 {
396 name: "muted italic description style",
397 styles: func() RenderStyles {
398 muted := renderer.DefaultStyle()
399 muted.Italic = true
400 muted.HasForegroundRGB = true
401 muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30}
402 styles := DefaultRenderStyles()
403 styles.Description = muted
404 return styles
405 },
406 x: 4,
407 y: 2,
408 unfiltered: true,
409 assert: func(t *testing.T, style renderer.Style) {
410 require.True(t, style.Italic)
411 require.True(t, style.HasForegroundRGB)
412 require.Equal(t, 0, style.Foreground, "inactive indexed color is canonicalized")
413 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
414 },
415 },
416 {
417 name: "description italic is configurable",
418 styles: func() RenderStyles {
419 styles := DefaultRenderStyles()
420 styles.Description.Italic = false
421 styles.SelectionDescription.Italic = false
422 return styles
423 },
424 x: 4,
425 y: 1,
426 assert: func(t *testing.T, style renderer.Style) {
427 require.False(t, style.Italic)
428 },
429 },
430 }
431
432 for _, tt := range tests {
433 t.Run(tt.name, func(t *testing.T) {
434 m := New(CommandResults([]command.Command{
435 cmd("CPY", "Copy", "Enter copy mode"),
436 cmd("CNT", "New", "Create tab"),
437 }))
438 if !tt.unfiltered {
439 m.Insert('c')
440 m.Insert('y')
441 }
442 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
443
444 tt.assert(t, frame.At(tt.x, tt.y).Style)
445 })
446 }
447}
448
449func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
450 selection := renderer.DefaultStyle()
451 selection.HasForegroundRGB = true
452 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
453 selection.HasBackgroundRGB = true
454 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
455 description := renderer.DefaultStyle()
456 description.Italic = true
457 description.HasForegroundRGB = true
458 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
459 selectionDescription := selection
460 selectionDescription.Italic = true
461 selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
462
463 model := New(CommandResults([]command.Command{
464 cmd("ONE", "One", "first description"),
465 cmd("TWO", "Two", "second description"),
466 }))
467 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
468 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
469 Selection: selection, Description: description, SelectionDescription: selectionDescription,
470 }})
471
472 selected := frame.At(4, 1).Style
473 require.True(t, selected.Equal(selectionDescription))
474
475 inactive := frame.At(4, 2).Style
476 require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
477 require.True(t, inactive.Italic)
478}
479
480func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
481 selection := renderer.DefaultStyle()
482 selection.HasForegroundRGB = true
483 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
484 selection.HasBackgroundRGB = true
485 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
486 description := renderer.DefaultStyle()
487 description.Italic = true
488 description.HasForegroundRGB = true
489 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
490
491 model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
492 frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
493 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
494 Selection: selection, Description: description,
495 }})
496
497 selected := frame.At(4, 1).Style
498 require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
499 require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
500 require.True(t, selected.Italic)
501}
502
503func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
504 paletteColors := [16]renderer.RGB{}
505 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
506 paletteColors[10] = paletteColors[2]
507 accentTheme := themeui.Theme{
508 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
509 HasFG: true, HasBG: true, Known: true, UsePalette: true,
510 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
511 }
512 indexedTheme := accentTheme
513 paletteOffTheme := accentTheme
514 paletteOffTheme.UsePalette = false
515 neutralTheme := paletteOffTheme
516 neutralTheme.PaletteKnown = 0
517
518 tests := []struct {
519 name string
520 theme themeui.Theme
521 policy domain.ThemeAccent
522 }{
523 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
524 {name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
525 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
526 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
527 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
528 {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
529 }
530
531 for _, tt := range tests {
532 t.Run(tt.name, func(t *testing.T) {
533 styles := themeui.Resolve(tt.theme, tt.policy).Styles
534 model := New(CommandResults([]command.Command{
535 cmd("ONE", "One", "first description"),
536 cmd("TWO", "Two", "second description"),
537 }))
538 model.Down()
539 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
540 Base: styles.PickerBase, Row: styles.SurfaceInactive,
541 Selection: styles.PickerSelection, Description: styles.PickerDescription,
542 SelectionDescription: styles.PickerSelectionMuted,
543 }})
544 inactive := frame.At(4, 1).Style
545 require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
546 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
547 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
548
549 selected := frame.At(4, 2).Style
550 require.True(t, selected.Equal(styles.PickerSelectionMuted))
551 })
552 }
553}
554
555func TestModelCompleteSelected(t *testing.T) {
556 jrs := cmd("JRS", "Jump", "Jump to recent session")
557 jrs.Arguments = command.ArgumentsRequired
558 optional := cmd("CNS", "New session", "Create named session")
559 optional.Arguments = command.ArgumentsOptional
560 registryFirst := command.Registry()[0]
561
562 tests := []struct {
563 name string
564 commands []command.Command
565 registry bool
566 query string
567 down int
568 want string
569 wantChange bool
570 rendered string
571 }{
572 {name: "nil model", want: "", wantChange: false},
573 {name: "empty model", commands: nil, want: "", wantChange: false},
574 {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
575 {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
576 {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
577 {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
578 {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
579 {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
580 {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
581 {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
582 {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
583 {name: "leading whitespace optional argument does not duplicate token", commands: []command.Command{optional}, query: "\u2003CNS name", want: "\u2003CNS name", wantChange: false},
584 {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
585 {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
586 }
587
588 for _, tt := range tests {
589 t.Run(tt.name, func(t *testing.T) {
590 var m *Model
591 if tt.name != "nil model" {
592 if tt.registry {
593 m = NewRegistry()
594 } else {
595 m = New(CommandResults(tt.commands))
596 }
597 for _, r := range tt.query {
598 m.Insert(r)
599 }
600 for range tt.down {
601 m.Down()
602 }
603 }
604
605 changed := m.CompleteSelected()
606 require.Equal(t, tt.wantChange, changed)
607 if m == nil {
608 require.Equal(t, tt.want, "")
609 return
610 }
611 require.Equal(t, tt.want, m.Query())
612 if tt.rendered != "" {
613 frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
614 require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
615 }
616 })
617 }
618}
619
620func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
621 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
622 workTarget := testExactTarget("work", 1)
623 results := []Result{
624 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
625 NewActiveSessionResult(workTarget, created),
626 NewStoppedSessionResult(testExactTarget("work", 2), created),
627 }
628 m := New(results)
629 results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created)
630
631 for _, r := range "switch" {
632 m.Insert(r)
633 }
634 matches := m.Matches()
635 require.Len(t, matches, 1)
636 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
637 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
638 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
639
640 selected, ok := m.Selected()
641 require.True(t, ok)
642 require.Equal(t, ResultKindActiveSession, selected.Kind())
643 target, active := selected.SessionTarget()
644 require.True(t, active)
645 require.Equal(t, workTarget, target)
646 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
647 require.Equal(t, "switch", m.Query())
648 _, argument := m.ArgumentCommand()
649 require.False(t, argument)
650
651 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
652 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
653}
654
655func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
656 m := New([]Result{NewStoppedSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
657 m.Insert('w')
658 m.Insert('k')
659
660 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
661
662 require.Equal(t, "Resume session work ", frameRow(frame, 1))
663 require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
664 require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
665 require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
666}
667
668func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
669 m := New([]Result{NewActiveSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
670 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
671 require.Len(t, m.Matches(), 1)
672 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
673}
674
675func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
676 jrs := cmd("JRS", "Jump", "Jump to recent session")
677 jrs.Arguments = command.ArgumentsRequired
678 jrs.ContextHint = command.ContextHintRecentSessions
679 m := New(CommandResults([]command.Command{jrs}))
680 for _, r := range "JRS 1" {
681 m.Insert(r)
682 }
683
684 frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
685 if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
686 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
687 }
688}
689
690func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
691 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
692 base := renderer.Style{Foreground: 1, Background: 2}
693 row := renderer.Style{Foreground: 3, Background: 4}
694 selection := renderer.Style{Foreground: 5, Background: 6}
695 frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
696
697 require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
698 require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
699 require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
700}