main ece7682

model_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close