v0.50.1 a637010

skill_pins.go RawHistoryFolder

1package modes
2
3import (
4	"slices"
5	"sync"
6
7	"github.com/bnema/zut/packages/agent/skills"
8	"github.com/bnema/zut/packages/tui"
9)
10
11// skillPinState holds the pinned-skill preload state of one interactive
12// conversation. Interactive owns one value and guards the mutable fields with
13// its own mutex, so every method except fetch and runToggle assumes the caller
14// holds that lock. Nothing here contacts a provider; the pending
15// bodies only join the first genuine user prompt (see consume).
16//
17// The three function fields are host callbacks (InteractiveConfig
18// LoadSkillPins, ToggleSkillPin, SkillSnapshot). Any may be nil: without
19// load there are no pins, without toggle the picker is read-only. They do
20// file and directory I/O, so they must never run under the interactive mutex.
21// Callers prefetch (fetch, runToggle) outside it and publish the result under
22// it (armFetched, applyRefresh, completeToggle).
23type skillPinState struct {
24	load     func(cwd string) (skills.Pins, error)
25	toggle   func(cwd, name string, global bool) error
26	snapshot func() []*skills.Skill
27
28	// ioMu serializes prefetch flows (fetch or toggle followed by publish) so
29	// a reload never publishes preferences older than a completed save. It is
30	// taken before agentMu and the interactive mutex; nothing waits for it
31	// while holding either.
32	ioMu sync.Mutex
33
34	pending  bool
35	pins     skills.Pins
36	selected []*skills.Skill
37	// gen changes whenever the conversation is armed or disarmed (fresh
38	// conversation, /clear, /cd, resume, import, fork); rev also changes when
39	// the preload is consumed or recalled. A prefetch records both so a result
40	// overtaken by newer state is never published over it.
41	gen uint64
42	rev uint64
43	// Remember the one expanded user prompt so queue recall and input history
44	// expose the user's draft, never the injected instruction bodies. The
45	// consumed selection is kept with it so recall restores exactly what was
46	// consumed without touching the disk again.
47	expanded         string
48	original         string
49	consumedSelected []*skills.Skill
50	consumedPins     skills.Pins
51}
52
53// skillPinFetch is the result of prefetching preferences (and, for arming,
54// the discoverable skills) outside the interactive mutex.
55type skillPinFetch struct {
56	pins      skills.Pins
57	err       error
58	available []*skills.Skill
59}
60
61// skillPinMark records the state a prefetch started from.
62type skillPinMark struct {
63	gen, rev uint64
64	cwd      string
65}
66
67// skillPinToggle is a validated picker request captured under the mutex and
68// executed outside it. list is the dialog's already-fetched skill list.
69type skillPinToggle struct {
70	cwd, name string
71	global    bool
72	list      []*skills.Skill
73}
74
75const skillPinsSavedNotice = "skill pins updated (apply on the next fresh conversation or /clear)"
76
77func newSkillPinState(load func(string) (skills.Pins, error), toggle func(string, string, bool) error, snapshot func() []*skills.Skill) skillPinState {
78	return skillPinState{load: load, toggle: toggle, snapshot: snapshot}
79}
80
81// mark captures the current generation, revision and cwd for a later publish.
82func (s *skillPinState) mark(cwd string) skillPinMark {
83	return skillPinMark{gen: s.gen, rev: s.rev, cwd: cwd}
84}
85
86// fetch loads the preferences for cwd and, when withSkills is set, the
87// discoverable skills. It touches no mutable state, so it runs without the
88// interactive mutex. Without a loader it does nothing.
89func (s *skillPinState) fetch(cwd string, withSkills bool) skillPinFetch {
90	var f skillPinFetch
91	if s.load == nil {
92		return f
93	}
94	f.pins, f.err = s.load(cwd)
95	if withSkills && f.err == nil && len(f.pins.Global)+len(f.pins.Project) > 0 && s.snapshot != nil {
96		f.available = s.snapshot()
97	}
98	return f
99}
100
101// disarm turns preloading off for a resumed, imported or forked conversation.
102// It never performs I/O.
103func (s *skillPinState) disarm() {
104	s.pending = false
105	s.selected = nil
106	s.expanded, s.original = "", ""
107	s.consumedSelected, s.consumedPins = nil, skills.Pins{}
108	s.gen++
109	s.rev++
110}
111
112// armFetched prepares preloading for a fresh conversation (or /clear, /cd)
113// from a prefetch made for the current cwd. Pending and the resolved
114// selection are published together. It returns warnings.
115func (s *skillPinState) armFetched(f skillPinFetch, d *skillsDialog) []string {
116	s.disarm()
117	if s.load == nil {
118		return nil
119	}
120	s.pending = true
121	s.pins = f.pins
122	if d != nil {
123		d.pins = f.pins
124		d.canPin = s.toggle != nil
125	}
126	return s.resolveLocked(f)
127}
128
129// resolveLocked resolves the selection from f and returns warnings for
130// unreadable preferences and pinned names no longer discoverable.
131func (s *skillPinState) resolveLocked(f skillPinFetch) []string {
132	var warnings []string
133	if f.err != nil {
134		warnings = append(warnings, f.err.Error())
135	}
136	var missing []string
137	s.selected, missing = f.pins.Resolve(f.available)
138	return append(warnings, missing...)
139}
140
141// applyRefresh publishes a prefetch made from m without changing whether the
142// preload is pending; cwd is the current working directory. A prefetch for
143// another cwd or conversation (gen) is dropped whole. One overtaken by a
144// consume or recall (rev) only feeds the dialog's markers, so it cannot
145// replace an exactly restored selection. Otherwise the state's pins are
146// updated and, while pending, the selection is re-resolved from f.available.
147func (s *skillPinState) applyRefresh(m skillPinMark, cwd string, f skillPinFetch, d *skillsDialog) []string {
148	if s.load == nil || m.cwd != cwd || m.gen != s.gen {
149		return nil
150	}
151	if d != nil {
152		d.pins = f.pins
153		d.canPin = s.toggle != nil
154	}
155	var warnings []string
156	if m.rev == s.rev {
157		s.pins = f.pins
158		if s.pending {
159			return s.resolveLocked(f)
160		}
161	}
162	if f.err != nil {
163		warnings = append(warnings, f.err.Error())
164	}
165	return warnings
166}
167
168// toggleRequest validates a picker key: 'p' (project) or 'g' (global) on a
169// pinnable, listed skill. ok is false when the key is not a pin key or the
170// picker is not in a state that pins (closed, body view, empty, read-only
171// host); the caller then forwards the key to the dialog.
172func (s *skillPinState) toggleRequest(k tui.Key, cwd string, d *skillsDialog) (req skillPinToggle, ok bool) {
173	if k.Kind != tui.KeyRune || (k.Rune != 'p' && k.Rune != 'g') {
174		return req, false
175	}
176	sk := d.selectedSkill()
177	if sk == nil || !d.canPin || s.toggle == nil {
178		return req, false
179	}
180	return skillPinToggle{cwd: cwd, name: sk.Name, global: k.Rune == 'g', list: d.skills}, true
181}
182
183// runToggle saves the toggle and reloads the preferences. It performs file
184// I/O and must run without the interactive mutex.
185func (s *skillPinState) runToggle(req skillPinToggle) (f skillPinFetch, saveErr error) {
186	if saveErr = s.toggle(req.cwd, req.name, req.global); saveErr != nil {
187		return f, saveErr
188	}
189	f = s.fetch(req.cwd, false)
190	f.available = req.list
191	return f, nil
192}
193
194// completeToggle publishes a finished toggle. statusErr carries a save
195// failure and statusOK the success notice; pins are refreshed on success from
196// the dialog's own skill list.
197func (s *skillPinState) completeToggle(m skillPinMark, cwd string, f skillPinFetch, saveErr error, d *skillsDialog) (statusOK, statusErr string, warnings []string) {
198	if saveErr != nil {
199		return "", "save skill pin: " + saveErr.Error(), nil
200	}
201	return skillPinsSavedNotice, "", s.applyRefresh(m, cwd, f, d)
202}
203
204// consume folds the pending skill bodies into prompt exactly once. Later calls
205// return the prompt unchanged until the conversation is armed again. The
206// consumed selection stays in the state so recall can restore it exactly.
207func (s *skillPinState) consume(prompt string) string {
208	if !s.pending {
209		return prompt
210	}
211	s.pending = false
212	s.rev++
213	out := skills.PreloadPrompt(s.selected, prompt)
214	if out != prompt {
215		s.expanded, s.original = out, prompt
216		s.consumedSelected, s.consumedPins = s.selected, s.pins
217	}
218	s.selected = nil
219	return out
220}
221
222// rawPrompt removes only the exact expansion generated by this conversation.
223// Arbitrary user text resembling a pinned-skill preamble is never stripped.
224func (s *skillPinState) rawPrompt(prompt string) string {
225	if s.expanded != "" && prompt == s.expanded {
226		return s.original
227	}
228	return prompt
229}
230
231// recall releases an undelivered preload so the next user submit can carry it.
232// It restores exactly the consumed selection without discovery or preference
233// I/O, so it is safe under the interactive mutex; queue removal serializes
234// delivery.
235func (s *skillPinState) recall(prompt string) {
236	if s.expanded == "" || prompt != s.expanded || s.load == nil {
237		return
238	}
239	s.pending = true
240	s.selected, s.pins = s.consumedSelected, s.consumedPins
241	s.rev++
242}
243
244// notice renders the read-only "[Pinned skills]" section shown above the
245// input while pins are pending, never as editor text.
246func (s *skillPinState) notice(th tui.Theme, cols int) []string {
247	if !s.pending || len(s.selected) == 0 || cols < 4 {
248		return nil
249	}
250	names := make([]string, 0, len(s.selected))
251	for _, sk := range s.selected {
252		name := sk.Name
253		project, global := s.pins.Scopes(s.selected, sk)
254		switch {
255		case project && global:
256			name += " (project + global)"
257		case project:
258			name += " (project)"
259		case global:
260			name += " (global)"
261		}
262		names = append(names, name)
263	}
264	return tui.RenderPinnedSkills(th, names, cols)
265}
266
267// SetPinnedSkillsPending arms (true) or disarms (false) the pinned-skill
268// preload for the current conversation. Hosts disarm after loading a resumed,
269// imported or forked session; /clear and /cd re-arm internally. Arming
270// prefetches outside the mutex and is abandoned if the preload state changed
271// meanwhile (including consumption or recall).
272func (i *Interactive) SetPinnedSkillsPending(pending bool) {
273	if !pending {
274		i.mu.Lock()
275		i.skillPins.disarm()
276		i.mu.Unlock()
277		i.invalidate()
278		return
279	}
280	i.skillPins.ioMu.Lock()
281	defer i.skillPins.ioMu.Unlock()
282	i.mu.Lock()
283	rev := i.skillPins.rev
284	i.mu.Unlock()
285	var f skillPinFetch
286	cwd := i.skillPinCWD()
287	for {
288		f = i.skillPins.fetch(cwd, true)
289		i.mu.Lock()
290		if i.skillPins.rev != rev {
291			i.mu.Unlock()
292			return
293		}
294		if i.cfg.CWD == cwd {
295			break
296		}
297		cwd = i.cfg.CWD
298		i.mu.Unlock()
299	}
300	i.armSkillPinsLocked(f)
301	i.mu.Unlock()
302	i.invalidate()
303}
304
305// skillPinCWD reads the working directory under the mutex.
306func (i *Interactive) skillPinCWD() string {
307	i.mu.Lock()
308	defer i.mu.Unlock()
309	return i.cfg.CWD
310}
311
312// lockWithSkillPinFetch prefetches the preferences and discoverable skills for
313// the current cwd without the mutex, then returns holding i.mu with the fetch
314// still matching i.cfg.CWD (it refetches if the cwd moved meanwhile). The
315// caller publishes with armSkillPinsLocked, unlocks i.mu, then calls done.
316func (i *Interactive) lockWithSkillPinFetch() (f skillPinFetch, done func()) {
317	i.skillPins.ioMu.Lock()
318	cwd := i.skillPinCWD()
319	for {
320		f = i.skillPins.fetch(cwd, true)
321		i.mu.Lock()
322		if i.cfg.CWD == cwd {
323			return f, i.skillPins.ioMu.Unlock
324		}
325		cwd = i.cfg.CWD
326		i.mu.Unlock()
327	}
328}
329
330// armSkillPinsLocked arms the preload for a fresh conversation from a prefetch
331// for the current cwd and reports warnings once. The caller must hold i.mu.
332func (i *Interactive) armSkillPinsLocked(f skillPinFetch) {
333	i.addSkillPinWarningsLocked(i.skillPins.armFetched(f, i.skillsDialog))
334}
335
336// addSkillPinWarningsLocked shows pin warnings in the host-only reload error
337// list, skipping ones already shown. The caller must hold i.mu.
338func (i *Interactive) addSkillPinWarningsLocked(warnings []string) {
339	for _, w := range warnings {
340		if w = "skill pins: " + w; !slices.Contains(i.reloadErrors, w) {
341			i.reloadErrors = append(i.reloadErrors, w)
342		}
343	}
344}
345
346// refreshPendingSkillPins re-resolves a pending selection (entry.pre may have
347// added or removed skills). Nothing is read when nothing is pending. The
348// callbacks run without the mutex; a result overtaken by a consume, recall,
349// arm or cwd change is dropped.
350func (i *Interactive) refreshPendingSkillPins() {
351	i.skillPins.ioMu.Lock()
352	defer i.skillPins.ioMu.Unlock()
353	i.mu.Lock()
354	if !i.skillPins.pending || i.skillPins.load == nil {
355		i.mu.Unlock()
356		return
357	}
358	mark := i.skillPins.mark(i.cfg.CWD)
359	i.mu.Unlock()
360	f := i.skillPins.fetch(mark.cwd, true)
361	i.mu.Lock()
362	i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog))
363	i.mu.Unlock()
364}
365
366// openSkillsDialogWithPins opens the picker on a single skill snapshot. The
367// preferences load outside the mutex, and a pending selection re-resolves from
368// that same list rather than walking discovery again. If the cwd moved while
369// loading, the whole prefetch is redone.
370func (i *Interactive) openSkillsDialogWithPins() {
371	i.skillPins.ioMu.Lock()
372	defer i.skillPins.ioMu.Unlock()
373	for {
374		i.mu.Lock()
375		mark := i.skillPins.mark(i.cfg.CWD)
376		i.mu.Unlock()
377		var list []*skills.Skill
378		if i.cfg.SkillSnapshot != nil {
379			list = i.cfg.SkillSnapshot()
380		}
381		f := i.skillPins.fetch(mark.cwd, false)
382		f.available = list
383		i.mu.Lock()
384		if i.cfg.CWD == mark.cwd {
385			i.skillsDialog.Open(list)
386			i.addSkillPinWarningsLocked(i.skillPins.applyRefresh(mark, i.cfg.CWD, f, i.skillsDialog))
387			i.mu.Unlock()
388			return
389		}
390		i.mu.Unlock()
391	}
392}
393
394// consumePinnedSkills folds pending pins into the first genuine user prompt.
395func (i *Interactive) consumePinnedSkills(prompt string) string {
396	i.mu.Lock()
397	defer i.mu.Unlock()
398	return i.skillPins.consume(prompt)
399}
400
401// handleSkillPinKey applies the picker's p/g keys. It reports whether the key
402// was consumed. The preference save and reload run without the mutex; the
403// result is applied under it from the dialog's own skill list.
404func (i *Interactive) handleSkillPinKey(k tui.Key) bool {
405	i.mu.Lock()
406	req, ok := i.skillPins.toggleRequest(k, i.cfg.CWD, i.skillsDialog)
407	i.mu.Unlock()
408	if !ok {
409		return false
410	}
411	i.skillPins.ioMu.Lock()
412	defer i.skillPins.ioMu.Unlock()
413	i.mu.Lock()
414	mark := i.skillPins.mark(req.cwd)
415	i.mu.Unlock()
416	f, saveErr := i.skillPins.runToggle(req)
417	i.mu.Lock()
418	okText, errText, warnings := i.skillPins.completeToggle(mark, i.cfg.CWD, f, saveErr, i.skillsDialog)
419	i.statusOK, i.statusErr = okText, errText
420	i.addSkillPinWarningsLocked(warnings)
421	i.mu.Unlock()
422	return true
423}

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