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}