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.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}

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