v0.3.96 c5883a4

skill_pins_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/mattn/go-runewidth"
11	"github.com/patriceckhart/zot/packages/agent/skills"
12	"github.com/patriceckhart/zot/packages/core"
13	"github.com/patriceckhart/zot/packages/provider"
14	"github.com/patriceckhart/zot/packages/tui"
15)
16
17func pinnedTestConfig() InteractiveConfig {
18	return InteractiveConfig{
19		PreloadPinnedSkills: true,
20		LoadSkillPins:       func(string) (skills.Pins, error) { return skills.Pins{Global: []string{"review"}}, nil },
21		SkillSnapshot:       func() []*skills.Skill { return []*skills.Skill{{Name: "review", Body: "Inspect carefully."}} },
22	}
23}
24
25func TestPinnedSkillsLifecycle(t *testing.T) {
26	i := NewInteractive(pinnedTestConfig())
27	i.ed.SetValue("typed request")
28	if got := strings.Join(i.pinnedSkillsNotice(80), ""); !strings.Contains(got, "review") {
29		t.Fatal(got)
30	}
31	if got := i.ed.Value(); got != "typed request" {
32		t.Fatal("notice changed editor")
33	}
34	first := i.preloadPinnedPromptLocked("first")
35	if !strings.Contains(first, "Inspect carefully.") {
36		t.Fatal(first)
37	}
38	if got := i.preloadPinnedPromptLocked("second"); got != "second" {
39		t.Fatal(got)
40	}
41	if len(i.pinnedSkillsNotice(80)) != 0 {
42		t.Fatal("notice survived consumption")
43	}
44	i.runSlash(context.Background(), "/clear")
45	if !strings.Contains(i.preloadPinnedPromptLocked("after clear"), "Inspect carefully.") {
46		t.Fatal("clear did not rearm pins")
47	}
48	i.SetPinnedSkillsPending(true)
49	i.SetPinnedSkillsPending(false)
50	if got := i.preloadPinnedPromptLocked("resumed"); got != "resumed" {
51		t.Fatal("resume reinjected pins")
52	}
53	cfg := pinnedTestConfig()
54	cfg.PreloadPinnedSkills = false
55	resumed := NewInteractive(cfg)
56	if got := resumed.preloadPinnedPromptLocked("existing"); got != "existing" {
57		t.Fatal("resumed startup injected pins")
58	}
59}
60
61func TestSkillPinPickerTogglesAndSaveFailure(t *testing.T) {
62	cfg := pinnedTestConfig()
63	pins := skills.Pins{}
64	fail := false
65	cfg.LoadSkillPins = func(string) (skills.Pins, error) { return pins, nil }
66	cfg.ToggleSkillPin = func(cwd, name string, global bool) error {
67		if fail {
68			return errors.New("read-only preferences")
69		}
70		if name != "review" {
71			t.Fatalf("name = %q", name)
72		}
73		if global {
74			if len(pins.Global) == 0 {
75				pins.Global = []string{name}
76			} else {
77				pins.Global = nil
78			}
79		} else {
80			if len(pins.Project) == 0 {
81				pins.Project = []string{name}
82			} else {
83				pins.Project = nil
84			}
85		}
86		return nil
87	}
88	i := NewInteractive(cfg)
89	i.openSkillsDialog()
90	for _, key := range []rune{'p', 'g'} {
91		if !i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: key}) {
92			t.Fatal("key ignored")
93		}
94	}
95	rendered := strings.Join(i.skillsDialog.Render(tui.Theme{}, 100), "\n")
96	if !strings.Contains(rendered, "[pg]") {
97		t.Fatal(rendered)
98	}
99	if len(i.pinnedSkills) != 1 {
100		t.Fatal("scope union duplicated skill")
101	}
102	i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
103	if len(i.pinnedSkills) != 1 || len(i.skillsDialog.pins.Project) != 0 {
104		t.Fatal("project unpin removed global pin")
105	}
106	fail = true
107	i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'g'})
108	if !strings.Contains(i.statusErr, "read-only") || len(i.skillsDialog.pins.Global) != 1 {
109		t.Fatal("save failure was not preserved")
110	}
111	i.skillsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
112	if i.handleSkillPinKey(tui.Key{Kind: tui.KeyRune, Rune: 'g'}) {
113		t.Fatal("body view toggled a pin")
114	}
115}
116
117func TestPinnedSkillsNoticeShowsScopes(t *testing.T) {
118	cfg := pinnedTestConfig()
119	cfg.LoadSkillPins = func(string) (skills.Pins, error) {
120		return skills.Pins{
121			Project: []string{"local", "shared"},
122			Global:  []string{"everywhere", "shared"},
123		}, nil
124	}
125	cfg.SkillSnapshot = func() []*skills.Skill {
126		return []*skills.Skill{{Name: "local"}, {Name: "everywhere"}, {Name: "shared"}}
127	}
128	i := NewInteractive(cfg)
129	text := stripANSIBytes(strings.Join(i.pinnedSkillsNotice(120), "\n"))
130	for _, want := range []string{"local (project)", "everywhere (global)", "shared (project + global)"} {
131		if !strings.Contains(text, want) {
132			t.Errorf("notice missing %q: %q", want, text)
133		}
134	}
135	if strings.Count(text, "shared") != 1 {
136		t.Fatalf("both scopes duplicated the skill: %q", text)
137	}
138}
139
140func TestPinnedSkillsWarningsAndNoticeWidth(t *testing.T) {
141	cfg := pinnedTestConfig()
142	cfg.LoadSkillPins = func(string) (skills.Pins, error) {
143		return skills.Pins{Global: []string{"missing", "review"}}, nil
144	}
145	i := NewInteractive(cfg)
146	i.refreshSkillPins()
147	if len(i.reloadErrors) != 1 || !strings.Contains(i.reloadErrors[0], "missing") {
148		t.Fatalf("warnings = %v", i.reloadErrors)
149	}
150	i.pinnedSkills = []*skills.Skill{{Name: strings.Repeat("界", 30)}}
151	for _, width := range []int{1, 4, 12, 30, 80} {
152		for _, row := range i.pinnedSkillsNotice(width) {
153			if got := runewidth.StringWidth(stripANSIBytes(row)); got > width {
154				t.Fatalf("width %d exceeds %d", got, width)
155			}
156		}
157	}
158}
159
160func TestPinnedSkillsEnterProviderAndPersistedTranscript(t *testing.T) {
161	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
162	defer cancel()
163	client := &preparationCaptureClient{requests: make(chan provider.Request, 1)}
164	ag := core.NewAgent(client, "test", "base", nil)
165	sess, err := core.NewSession(t.TempDir(), t.TempDir(), "test", "test", "test")
166	if err != nil {
167		t.Fatal(err)
168	}
169	defer sess.Close()
170	persisted := make(chan error, 4)
171	ag.OnMessageAppended = func(m provider.Message) { persisted <- sess.AppendMessage(m) }
172	done := make(chan struct{}, 1)
173	cfg := pinnedTestConfig()
174	cfg.Agent = ag
175	cfg.FlushSession = func() { done <- struct{}{} }
176	i := NewInteractive(cfg)
177	// Construction only prepares the notice. It must not contact a provider.
178	select {
179	case <-client.requests:
180		t.Fatal("startup invoked model")
181	default:
182	}
183	image := provider.ImageBlock{MimeType: "image/png", Data: []byte("image")}
184	i.startTurnWithImages(ctx, "question", []provider.ImageBlock{image})
185	select {
186	case req := <-client.requests:
187		content := req.Messages[0].Content
188		if len(content) != 2 {
189			t.Fatalf("content = %#v", content)
190		}
191		text := content[0].(provider.TextBlock).Text
192		if !strings.Contains(text, "Inspect carefully.") || !strings.HasSuffix(text, "question") {
193			t.Fatal(text)
194		}
195		if _, ok := content[1].(provider.ImageBlock); !ok {
196			t.Fatal("image lost")
197		}
198	case <-ctx.Done():
199		t.Fatal("provider not called")
200	}
201	select {
202	case <-done:
203	case <-ctx.Done():
204		t.Fatal("turn did not finish")
205	}
206	for range 2 {
207		select {
208		case err := <-persisted:
209			if err != nil {
210				t.Fatal(err)
211			}
212		case <-ctx.Done():
213			t.Fatal("message not persisted")
214		}
215	}
216	reopened, messages, err := core.OpenSession(sess.Path)
217	if err != nil {
218		t.Fatal(err)
219	}
220	defer reopened.Close()
221	if len(messages) != 2 || !strings.Contains(messages[0].Content[0].(provider.TextBlock).Text, "Inspect carefully.") {
222		t.Fatalf("messages = %#v", messages)
223	}
224}

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 476

↑↓ moveenter openesc close