v0.50.1 a637010

skill_pins_test.go RawHistoryFolder

1package modes
2
3import (
4	"errors"
5	"slices"
6	"strings"
7	"testing"
8
9	"github.com/bnema/zut/packages/agent/skills"
10	"github.com/bnema/zut/packages/tui"
11	"github.com/mattn/go-runewidth"
12)
13
14func reviewSnapshot() []*skills.Skill {
15	return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}}
16}
17
18func pinKey(r rune) tui.Key { return tui.Key{Kind: tui.KeyRune, Rune: r} }
19
20func TestSkillPinStateConsumesOnceAndRearms(t *testing.T) {
21	load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }
22	s := newSkillPinState(load, nil, reviewSnapshot)
23	s.arm(true, "/w", nil)
24	if got := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 80), "")); !strings.Contains(got, "review (global)") {
25		t.Fatalf("notice = %q", got)
26	}
27	first := s.consume("first")
28	if !strings.Contains(first, "Inspect carefully.") || !strings.HasSuffix(first, "first") {
29		t.Fatal(first)
30	}
31	if got := s.consume("second"); got != "second" {
32		t.Fatal(got)
33	}
34	if len(s.notice(tui.Theme{}, 80)) != 0 {
35		t.Fatal("notice survived consumption")
36	}
37	// /clear and /cd re-arm.
38	s.arm(true, "/w", nil)
39	if !strings.Contains(s.consume("after clear"), "Inspect carefully.") {
40		t.Fatal("re-arm did not restore pins")
41	}
42	// Resume/import/fork disarm even after arming.
43	s.arm(true, "/w", nil)
44	s.arm(false, "/w", nil)
45	if got := s.consume("resumed"); got != "resumed" {
46		t.Fatal("resume reinjected pins")
47	}
48	// Refresh (model swap, /skills) never re-arms a consumed state.
49	s.refresh("/w", nil)
50	if got := s.consume("later"); got != "later" {
51		t.Fatal("refresh re-armed pins")
52	}
53	// No loader means nothing is ever pending.
54	none := newSkillPinState(nil, nil, reviewSnapshot)
55	none.arm(true, "/w", nil)
56	if got := none.consume("x"); got != "x" {
57		t.Fatal(got)
58	}
59}
60
61func TestSkillPinStateTogglesAndSaveFailure(t *testing.T) {
62	pins := skills.Pins{}
63	fail := false
64	flip := func(names []string, name string) []string {
65		if slices.Contains(names, name) {
66			return nil
67		}
68		return []string{name}
69	}
70	toggle := func(_, name string, global bool) error {
71		if fail {
72			return errors.New("read-only preferences")
73		}
74		if global {
75			pins.Global = flip(pins.Global, name)
76		} else {
77			pins.Project = flip(pins.Project, name)
78		}
79		return nil
80	}
81	s := newSkillPinState(func(string) (skills.Pins, error) { return pins, nil }, toggle, reviewSnapshot)
82	d := newSkillsDialog()
83	d.Open(reviewSnapshot())
84	s.arm(true, "/w", d)
85
86	for _, r := range []rune{'p', 'g'} {
87		handled, ok, errText, _ := s.handleKey(pinKey(r), "/w", d)
88		if !handled || errText != "" || ok == "" {
89			t.Fatalf("key %c: handled=%v ok=%q err=%q", r, handled, ok, errText)
90		}
91	}
92	if rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n"); !strings.Contains(rendered, "[pg]") {
93		t.Fatal(rendered)
94	}
95	if len(s.selected) != 1 {
96		t.Fatal("scope union duplicated skill")
97	}
98	s.handleKey(pinKey('p'), "/w", d)
99	if len(s.selected) != 1 || len(d.pins.Project) != 0 {
100		t.Fatal("project unpin removed global pin")
101	}
102	fail = true
103	handled, _, errText, _ := s.handleKey(pinKey('g'), "/w", d)
104	if !handled || !strings.Contains(errText, "read-only") || len(d.pins.Global) != 1 {
105		t.Fatalf("save failure not preserved: %q", errText)
106	}
107	// Body view and other keys are not pin keys.
108	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
109	if handled, _, _, _ := s.handleKey(pinKey('g'), "/w", d); handled {
110		t.Fatal("body view toggled a pin")
111	}
112	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
113	if handled, _, _, _ := s.handleKey(pinKey('x'), "/w", d); handled {
114		t.Fatal("unrelated key handled")
115	}
116}
117
118func TestSkillPinStateReadOnlyHost(t *testing.T) {
119	s := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil }, nil, reviewSnapshot)
120	d := newSkillsDialog()
121	d.Open(reviewSnapshot())
122	s.arm(true, "/w", d)
123	if handled, _, _, _ := s.handleKey(pinKey('p'), "/w", d); handled {
124		t.Fatal("pin key handled without ToggleSkillPin")
125	}
126	rendered := strings.Join(d.Render(tui.Theme{}, 100), "\n")
127	if strings.Contains(rendered, "[-g]") || strings.Contains(rendered, "p: project pin") {
128		t.Fatalf("read-only host shows pin controls: %s", rendered)
129	}
130	// The pending notice still informs the user.
131	if len(s.notice(tui.Theme{}, 80)) == 0 {
132		t.Fatal("pending notice missing")
133	}
134}
135
136func TestSkillPinStateNoticeShowsScopesOnce(t *testing.T) {
137	load := func(string) (skills.Pins, error) {
138		return skills.Pins{Project: []string{"local", "shared"}, Global: []string{"everywhere", "shared"}}, nil
139	}
140	snap := func() []*skills.Skill {
141		return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}}
142	}
143	s := newSkillPinState(load, nil, snap)
144	s.arm(true, "/w", nil)
145	text := stripANSIBytes(strings.Join(s.notice(tui.Theme{}, 120), "\n"))
146	for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} {
147		if !strings.Contains(text, want) {
148			t.Errorf("notice missing %q: %q", want, text)
149		}
150	}
151	if strings.Count(text, "shared") != 1 {
152		t.Fatalf("both scopes duplicated the skill: %q", text)
153	}
154}
155
156func TestSkillPinStateWarningsAndNoticeWidth(t *testing.T) {
157	load := func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"missing", "review"}}, nil }
158	s := newSkillPinState(load, nil, reviewSnapshot)
159	warnings := s.arm(true, "/w", nil)
160	if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
161		t.Fatalf("warnings = %v", warnings)
162	}
163	bad := newSkillPinState(func(string) (skills.Pins, error) { return skills.Pins{}, errors.New("invalid skill-pins.json") }, nil, reviewSnapshot)
164	if w := bad.arm(true, "/w", nil); len(w) != 1 || !strings.Contains(w[0], "invalid") {
165		t.Fatalf("malformed warning = %v", w)
166	}
167	s.selected = []*skills.Skill{{Name: strings.Repeat("界", 30)}}
168	for _, width := range []int{1, 4, 12, 30, 80} {
169		for _, row := range s.notice(tui.Theme{}, width) {
170			if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
171				t.Fatalf("width %d exceeds %d", got, width)
172			}
173		}
174	}
175}

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 1

Tags 477

↑↓ moveenter openesc close