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, UsePalette: true,
507		Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
508	}
509	indexedTheme := accentTheme
510	paletteOffTheme := accentTheme
511	paletteOffTheme.UsePalette = false
512	neutralTheme := paletteOffTheme
513	neutralTheme.PaletteKnown = 0
514
515	tests := []struct {
516		name   string
517		theme  themeui.Theme
518		policy domain.ThemeAccent
519	}{
520		{name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
521		{name: "ansi256", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
522		{name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
523		{name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
524		{name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
525		{name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
526	}
527
528	for _, tt := range tests {
529		t.Run(tt.name, func(t *testing.T) {
530			styles := themeui.Resolve(tt.theme, tt.policy).Styles
531			model := New(CommandResults([]command.Command{
532				cmd("ONE", "One", "first description"),
533				cmd("TWO", "Two", "second description"),
534			}))
535			model.Down()
536			frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
537				Base: styles.PickerBase, Row: styles.SurfaceInactive,
538				Selection: styles.PickerSelection, Description: styles.PickerDescription,
539				SelectionDescription: styles.PickerSelectionMuted,
540			}})
541			inactive := frame.At(4, 1).Style
542			require.Equal(t, styles.SurfaceInactive.Canonical().Background, inactive.Background)
543			require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
544			require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
545
546			selected := frame.At(4, 2).Style
547			require.True(t, selected.Equal(styles.PickerSelectionMuted))
548		})
549	}
550}
551
552func TestModelCompleteSelected(t *testing.T) {
553	jrs := cmd("JRS", "Jump", "Jump to recent session")
554	jrs.Arguments = command.ArgumentsRequired
555	optional := cmd("CNS", "New session", "Create named session")
556	optional.Arguments = command.ArgumentsOptional
557	registryFirst := command.Registry()[0]
558
559	tests := []struct {
560		name       string
561		commands   []command.Command
562		registry   bool
563		query      string
564		down       int
565		want       string
566		wantChange bool
567		rendered   string
568	}{
569		{name: "nil model", want: "", wantChange: false},
570		{name: "empty model", commands: nil, want: "", wantChange: false},
571		{name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
572		{name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
573		{name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
574		{name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
575		{name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
576		{name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
577		{name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
578		{name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα  β", want: "JRS α  β", wantChange: true},
579		{name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003  α  β", want: "JRS\u2003  α  β", wantChange: false},
580		{name: "leading whitespace optional argument does not duplicate token", commands: []command.Command{optional}, query: "\u2003CNS name", want: "\u2003CNS name", wantChange: false},
581		{name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
582		{name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
583	}
584
585	for _, tt := range tests {
586		t.Run(tt.name, func(t *testing.T) {
587			var m *Model
588			if tt.name != "nil model" {
589				if tt.registry {
590					m = NewRegistry()
591				} else {
592					m = New(CommandResults(tt.commands))
593				}
594				for _, r := range tt.query {
595					m.Insert(r)
596				}
597				for range tt.down {
598					m.Down()
599				}
600			}
601
602			changed := m.CompleteSelected()
603			require.Equal(t, tt.wantChange, changed)
604			if m == nil {
605				require.Equal(t, tt.want, "")
606				return
607			}
608			require.Equal(t, tt.want, m.Query())
609			if tt.rendered != "" {
610				frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
611				require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
612			}
613		})
614	}
615}
616
617func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
618	created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
619	workTarget := testExactTarget("work", 1)
620	results := []Result{
621		NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
622		NewActiveSessionResult(workTarget, created),
623		NewStoppedSessionResult(testExactTarget("work", 2), created),
624	}
625	m := New(results)
626	results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created)
627
628	for _, r := range "switch" {
629		m.Insert(r)
630	}
631	matches := m.Matches()
632	require.Len(t, matches, 1)
633	require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
634	matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
635	require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
636
637	selected, ok := m.Selected()
638	require.True(t, ok)
639	require.Equal(t, ResultKindActiveSession, selected.Kind())
640	target, active := selected.SessionTarget()
641	require.True(t, active)
642	require.Equal(t, workTarget, target)
643	require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
644	require.Equal(t, "switch", m.Query())
645	_, argument := m.ArgumentCommand()
646	require.False(t, argument)
647
648	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
649	require.Equal(t, "Switch to session work      ", frameRow(frame, 1))
650}
651
652func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
653	m := New([]Result{NewStoppedSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
654	m.Insert('w')
655	m.Insert('k')
656
657	frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
658
659	require.Equal(t, "Resume session work         ", frameRow(frame, 1))
660	require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
661	require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
662	require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
663}
664
665func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
666	m := New([]Result{NewActiveSessionResult(testExactTarget("work", 1), time.Unix(0, 1))})
667	frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
668	require.Len(t, m.Matches(), 1)
669	require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
670}
671
672func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
673	jrs := cmd("JRS", "Jump", "Jump to recent session")
674	jrs.Arguments = command.ArgumentsRequired
675	jrs.ContextHint = command.ContextHintRecentSessions
676	m := New(CommandResults([]command.Command{jrs}))
677	for _, r := range "JRS 1" {
678		m.Insert(r)
679	}
680
681	frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
682	if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
683		t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
684	}
685}
686
687func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
688	m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
689	base := renderer.Style{Foreground: 1, Background: 2}
690	row := renderer.Style{Foreground: 3, Background: 4}
691	selection := renderer.Style{Foreground: 5, Background: 6}
692	frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
693
694	require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
695	require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
696	require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
697}

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