detached 97ae0a2

feat: add project and global skill pins

commit
97ae0a24cdd00bb6fb28d7314db0d15ceda426da
author
patriceckhart · · 2026-09-21 16:27 UTC
parent
d66bd89
signature
unsigned
refs
tag: v0.3.84
13 files · +956 -34
M README.md +6 -0
@@ -477,6 +477,12 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
477477
478478 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
479479
480+Press `p` or `g` in the skills list to toggle project or global pins. Pinned
481+instructions accompany your first message in a fresh conversation and after
482+`/clear`, without starting a model turn on their own. A read-only notice above
483+the input shows the pending skills. Resuming does not reload pins. See
484+[pinning skills](docs/skills.md#pinning-skills) for scope, storage, and mode support.
485+
480486 ### `/compact`
481487
482488 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. Press `ctrl+o` to expand the summary. Expanded text wraps to the current terminal width, including after resizing.
@@ -477,6 +477,12 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
477 477
478 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.478 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
479 479
480+Press `p` or `g` in the skills list to toggle project or global pins. Pinned
481+instructions accompany your first message in a fresh conversation and after
482+`/clear`, without starting a model turn on their own. A read-only notice above
483+the input shows the pending skills. Resuming does not reload pins. See
484+[pinning skills](docs/skills.md#pinning-skills) for scope, storage, and mode support.
485+
480 ### `/compact`486 ### `/compact`
481 487
482 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. Press `ctrl+o` to expand the summary. Expanded text wraps to the current terminal width, including after resizing.488 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. Press `ctrl+o` to expand the summary. Expanded text wraps to the current terminal width, including after resizing.
M docs/skills.md +58 -0
@@ -89,6 +89,64 @@ In zot, run `/skills`. A picker lists every discovered skill with its
8989 description and source path. Press enter on a row to view the full
9090 body inline. Press esc to go back.
9191
92+## Pinning skills
93+
94+In the `/skills` list, press `p` to toggle a project pin or `g` to toggle a
95+global pin. Rows show `[p-]`, `[-g]`, or `[pg]` for the selected scopes.
96+Global and project pins are combined in alphabetical name order, with each
97+skill loaded only once. Unpinning one scope leaves the other scope intact.
98+
99+On a fresh interactive session, a read-only `[Pinned skills]` section above
100+the input lists the skills that will accompany your next message. Its heading,
101+spacing, and indented names match the other startup information sections.
102+Each name shows its pin scope: `(project)`, `(global)`, or `(project + global)`.
103+Opening zot does not start a model turn. The complete skill bodies and their
104+source directories are included
105+in your first submitted message and persisted with it, without changing the
106+editor text. `/clear` prepares pins again. Switching working directories with
107+`/cd` prepares the new directory's pins. Resuming, importing, or branching an
108+existing session does not inject pins again, even when its transcript is empty.
109+Changing models and compacting context do not reload pins.
110+
111+Changing pins before the first message updates the pending selection.
112+After that message, pin changes apply on the next fresh conversation or
113+`/clear`. Unpinning does not remove instructions already in the transcript.
114+There are no per-request removal controls in the editor.
115+
116+Preferences live in `$ZOT_HOME/skill-pins.json`, not in the repository.
117+Project keys are absolute starting directories with symlinks resolved when
118+possible, not Git roots. Subdirectories have separate project pins.
119+For example:
120+
121+```json
122+{
123+ "global": ["code-review"],
124+ "projects": {
125+ "/home/me/work/app": ["test-fix"]
126+ }
127+}
128+```
129+
130+Pins store discovered names, including extension namespaces, rather than
131+copies of skill files. Normal discovery precedence still applies, so a global
132+pin may resolve to a project's higher-priority skill of the same name.
133+Missing or disabled sources produce a warning and are skipped. A malformed
134+preferences file is reported and is not overwritten by a toggle.
135+`--no-skill` disables preloading. Explicitly pinning a skill with
136+`disable-model-invocation: true` is allowed.
137+
138+Print, stream, and JSON modes also include pins with the first main prompt of
139+a fresh session, including runs with session persistence disabled. Zotfile
140+startup `pre` commands run before pins are applied. RPC, standalone bot modes,
141+the SDK, and swarm background agents do not automatically load pin preferences.
142+The interactive Telegram bridge uses the interactive conversation's pins.
143+
144+Pinning saves repeated invocation, not tokens: complete skill bodies add to
145+the context window and may later be summarized by compaction. Keep pins small,
146+especially with local models. Preferences are saved via temporary-file replacement.
147+Concurrent edits from separate zot processes are not merged. An already open interactive
148+session refreshes preferences on `/skills` or `/clear`, not continuously.
149+
92150 ## Invoking skills
93151
94152 For normal skills, the system prompt tells the model the skill names and
@@ -89,6 +89,64 @@ In zot, run `/skills`. A picker lists every discovered skill with its
89 description and source path. Press enter on a row to view the full89 description and source path. Press enter on a row to view the full
90 body inline. Press esc to go back.90 body inline. Press esc to go back.
91 91
92+## Pinning skills
93+
94+In the `/skills` list, press `p` to toggle a project pin or `g` to toggle a
95+global pin. Rows show `[p-]`, `[-g]`, or `[pg]` for the selected scopes.
96+Global and project pins are combined in alphabetical name order, with each
97+skill loaded only once. Unpinning one scope leaves the other scope intact.
98+
99+On a fresh interactive session, a read-only `[Pinned skills]` section above
100+the input lists the skills that will accompany your next message. Its heading,
101+spacing, and indented names match the other startup information sections.
102+Each name shows its pin scope: `(project)`, `(global)`, or `(project + global)`.
103+Opening zot does not start a model turn. The complete skill bodies and their
104+source directories are included
105+in your first submitted message and persisted with it, without changing the
106+editor text. `/clear` prepares pins again. Switching working directories with
107+`/cd` prepares the new directory's pins. Resuming, importing, or branching an
108+existing session does not inject pins again, even when its transcript is empty.
109+Changing models and compacting context do not reload pins.
110+
111+Changing pins before the first message updates the pending selection.
112+After that message, pin changes apply on the next fresh conversation or
113+`/clear`. Unpinning does not remove instructions already in the transcript.
114+There are no per-request removal controls in the editor.
115+
116+Preferences live in `$ZOT_HOME/skill-pins.json`, not in the repository.
117+Project keys are absolute starting directories with symlinks resolved when
118+possible, not Git roots. Subdirectories have separate project pins.
119+For example:
120+
121+```json
122+{
123+ "global": ["code-review"],
124+ "projects": {
125+ "/home/me/work/app": ["test-fix"]
126+ }
127+}
128+```
129+
130+Pins store discovered names, including extension namespaces, rather than
131+copies of skill files. Normal discovery precedence still applies, so a global
132+pin may resolve to a project's higher-priority skill of the same name.
133+Missing or disabled sources produce a warning and are skipped. A malformed
134+preferences file is reported and is not overwritten by a toggle.
135+`--no-skill` disables preloading. Explicitly pinning a skill with
136+`disable-model-invocation: true` is allowed.
137+
138+Print, stream, and JSON modes also include pins with the first main prompt of
139+a fresh session, including runs with session persistence disabled. Zotfile
140+startup `pre` commands run before pins are applied. RPC, standalone bot modes,
141+the SDK, and swarm background agents do not automatically load pin preferences.
142+The interactive Telegram bridge uses the interactive conversation's pins.
143+
144+Pinning saves repeated invocation, not tokens: complete skill bodies add to
145+the context window and may later be summarized by compaction. Keep pins small,
146+especially with local models. Preferences are saved via temporary-file replacement.
147+Concurrent edits from separate zot processes are not merged. An already open interactive
148+session refreshes preferences on `/skills` or `/clear`, not continuously.
149+
92 ## Invoking skills150 ## Invoking skills
93 151
94 For normal skills, the system prompt tells the model the skill names and152 For normal skills, the system prompt tells the model the skill names and
M packages/agent/cli.go +36 -29
@@ -428,7 +428,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
428428
429429 ag := r.NewAgent()
430430 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
431- sess, _ := openOrCreateSession(args, r, ag, version)
431+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
432432 defer sess.Close()
433433
434434 piped, _ := readAllStdin()
@@ -442,6 +442,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
442442 return err
443443 }
444444 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
445+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
445446 started := time.Now()
446447 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
447448 elapsed := time.Since(started)
@@ -468,7 +469,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
468469
469470 ag := r.NewAgent()
470471 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
471- sess, _ := openOrCreateSession(args, r, ag, version)
472+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
472473 defer sess.Close()
473474
474475 piped, _ := readAllStdin()
@@ -485,6 +486,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
485486 }
486487 finishPre()
487488 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
489+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
488490 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
489491 WriteNewTranscript(ag, sess, start)
490492 return err
@@ -547,7 +549,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
547549
548550 ag := r.NewAgent()
549551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
550- sess, _ := openOrCreateSession(args, r, ag, version)
552+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
551553 defer sess.Close()
552554
553555 piped, _ := readAllStdin()
@@ -566,6 +568,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
566568 return err
567569 }
568570 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
571+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
569572 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
570573 WriteNewTranscript(ag, sess, start)
571574 return err
@@ -949,6 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
949952 })
950953
951954 var sess *core.Session
955+ freshSession := true
952956 var sessBaselineMsgs int // messages already on disk when current session opened
953957 // persistMu guards sess + sessBaselineMsgs against concurrent access
954958 // from the agent loop's per-message persistence hook (runs on the
@@ -957,7 +961,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
957961 // races with a finishing turn could double-write or lose messages.
958962 var persistMu sync.Mutex
959963 if ag != nil {
960- sess, _ = openOrCreateSession(args, r, ag, version)
964+ sess, freshSession, _ = openOrCreateSessionState(args, r, ag, version)
961965 if ag != nil {
962966 sessBaselineMsgs = len(ag.Messages())
963967 }
@@ -1087,6 +1091,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10871091 // the hydrated tail.
10881092 sessBaselineMsgs = fullMsgCount
10891093 persistMu.Unlock()
1094+ if iv != nil {
1095+ iv.SetPinnedSkillsPending(false)
1096+ }
10901097 // Re-scope the swarm dashboard to the new session so /swarm
10911098 // only shows agents this session spawned. swarmMgr may be nil
10921099 // here if we haven't reached the construction site yet (it
@@ -1223,6 +1230,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12231230 if iv != nil {
12241231 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
12251232 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
1233+ iv.SetPinnedSkillsPending(true)
12261234 }
12271235
12281236 // Re-scope the swarm dashboard to the new session.
@@ -1457,28 +1465,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14571465 }
14581466 _ = MarkChangelogShown(v)
14591467 },
1460- SkillSnapshot: func() []*skills.Skill {
1468+ PreloadPinnedSkills: freshSession && !args.NoSkill,
1469+ LoadSkillPins: func(cwd string) (skills.Pins, error) {
14611470 if args.NoSkill {
1462- // --no-skill: nothing for the picker to show.
1463- return nil
1464- }
1465- // Re-discover so the picker reflects edits made during
1466- // the session. Cheap; SKILL.md files are small. Filter
1467- // out built-in skills — they're hidden from user-facing
1468- // surfaces because they're implementation detail; the
1469- // model still sees them through the system-prompt
1470- // manifest and the skill tool.
1471- userHome, _ := os.UserHomeDir()
1472- var sources []skills.Source
1473- if args.WithSkills {
1474- sources = append(sources, skills.SearchSources(ZotHome(), r.CWD, userHome)...)
1475- }
1476- if !args.NoExt || len(args.Exts) > 0 {
1477- extSources, _ := extensions.PlanSkillSources(ZotHome(), r.CWD, args.Exts, !args.NoExt)
1478- sources = append(extSources, sources...)
1471+ return skills.Pins{}, nil
14791472 }
1480- list, _ := skills.DiscoverSources(sources, true)
1481- return skills.VisibleSkills(list)
1473+ return loadSkillPins(cwd)
1474+ },
1475+ ToggleSkillPin: toggleSkillPin,
1476+ SkillSnapshot: func() []*skills.Skill {
1477+ return userSkillSnapshot(args)
14821478 },
14831479 NoYolo: args.NoYolo,
14841480 ConfirmGate: confirmGate,
@@ -1574,9 +1570,16 @@ func agentSessionsRoot(root string, args Args) string {
15741570 // with a nil error if session persistence is disabled. When a session
15751571 // exists, its id is bound onto ag so providers can sticky-route.
15761572 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1573+ s, _, err := openOrCreateSessionState(args, r, ag, version)
1574+ return s, err
1575+}
1576+
1577+// The freshness result distinguishes an empty resumed session from a new one.
1578+func openOrCreateSessionState(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, bool, error) {
1579+ fresh := true
15771580 if args.NoSess {
15781581 setZotSessionEnvironment(r, nil)
1579- return nil, nil
1582+ return nil, fresh, nil
15801583 }
15811584 // Sweep meta-only files left over from older zot versions (and from
15821585 // any session that crashed before its first AppendMessage). Cheap;
@@ -1591,6 +1594,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
15911594 switch {
15921595 case args.Session != "":
15931596 s, msgs, err = core.OpenSession(args.Session)
1597+ fresh = false
15941598 // The swarm-agent child passes a fixed --session path that
15951599 // may not exist yet on first Spawn. Treat ENOENT as "create
15961600 // a fresh session AT THIS PATH" so the conversation actually
@@ -1601,29 +1605,32 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
16011605 // paths that already exist on disk.
16021606 if err != nil && errors.Is(err, os.ErrNotExist) {
16031607 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
1608+ fresh = true
16041609 msgs = nil
16051610 }
16061611 case args.Continue:
16071612 latest := core.LatestSession(sessionsRoot, args.CWD)
16081613 if latest != "" {
16091614 s, msgs, err = core.OpenSession(latest)
1615+ fresh = false
16101616 }
16111617 case args.Resume:
16121618 picked, perr := pickSession(sessionsRoot, args.CWD)
16131619 if perr != nil {
1614- return nil, perr
1620+ return nil, false, perr
16151621 }
16161622 if picked != "" {
16171623 s, msgs, err = core.OpenSession(picked)
1624+ fresh = false
16181625 }
16191626 }
16201627 if err != nil {
1621- return nil, err
1628+ return nil, false, err
16221629 }
16231630 if s == nil {
16241631 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
16251632 if err != nil {
1626- return nil, err
1633+ return nil, false, err
16271634 }
16281635 } else {
16291636 ag.SetMessages(msgs)
@@ -1634,7 +1641,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
16341641 }
16351642 bindAgentSession(ag, s)
16361643 setZotSessionEnvironment(r, s)
1637- return s, nil
1644+ return s, fresh, nil
16381645 }
16391646
16401647 // setZotSessionEnvironment publishes the non-secret runtime metadata that
@@ -428,7 +428,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
428 428
429 ag := r.NewAgent()429 ag := r.NewAgent()
430 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)430 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
431- sess, _ := openOrCreateSession(args, r, ag, version)431+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
432 defer sess.Close()432 defer sess.Close()
433 433
434 piped, _ := readAllStdin()434 piped, _ := readAllStdin()
@@ -442,6 +442,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
442 return err442 return err
443 }443 }
444 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)444 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
445+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
445 started := time.Now()446 started := time.Now()
446 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)447 usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
447 elapsed := time.Since(started)448 elapsed := time.Since(started)
@@ -468,7 +469,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
468 469
469 ag := r.NewAgent()470 ag := r.NewAgent()
470 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)471 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
471- sess, _ := openOrCreateSession(args, r, ag, version)472+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
472 defer sess.Close()473 defer sess.Close()
473 474
474 piped, _ := readAllStdin()475 piped, _ := readAllStdin()
@@ -485,6 +486,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
485 }486 }
486 finishPre()487 finishPre()
487 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)488 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
489+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
488 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)490 err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
489 WriteNewTranscript(ag, sess, start)491 WriteNewTranscript(ag, sess, start)
490 return err492 return err
@@ -547,7 +549,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
547 549
548 ag := r.NewAgent()550 ag := r.NewAgent()
549 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)551 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
550- sess, _ := openOrCreateSession(args, r, ag, version)552+ sess, fresh, _ := openOrCreateSessionState(args, r, ag, version)
551 defer sess.Close()553 defer sess.Close()
552 554
553 piped, _ := readAllStdin()555 piped, _ := readAllStdin()
@@ -566,6 +568,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
566 return err568 return err
567 }569 }
568 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)570 reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
571+ prompt = preloadSkillPins(args, fresh, prompt, os.Stderr)
569 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)572 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
570 WriteNewTranscript(ag, sess, start)573 WriteNewTranscript(ag, sess, start)
571 return err574 return err
@@ -949,6 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
949 })952 })
950 953
951 var sess *core.Session954 var sess *core.Session
955+ freshSession := true
952 var sessBaselineMsgs int // messages already on disk when current session opened956 var sessBaselineMsgs int // messages already on disk when current session opened
953 // persistMu guards sess + sessBaselineMsgs against concurrent access957 // persistMu guards sess + sessBaselineMsgs against concurrent access
954 // from the agent loop's per-message persistence hook (runs on the958 // from the agent loop's per-message persistence hook (runs on the
@@ -957,7 +961,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
957 // races with a finishing turn could double-write or lose messages.961 // races with a finishing turn could double-write or lose messages.
958 var persistMu sync.Mutex962 var persistMu sync.Mutex
959 if ag != nil {963 if ag != nil {
960- sess, _ = openOrCreateSession(args, r, ag, version)964+ sess, freshSession, _ = openOrCreateSessionState(args, r, ag, version)
961 if ag != nil {965 if ag != nil {
962 sessBaselineMsgs = len(ag.Messages())966 sessBaselineMsgs = len(ag.Messages())
963 }967 }
@@ -1087,6 +1091,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1087 // the hydrated tail.1091 // the hydrated tail.
1088 sessBaselineMsgs = fullMsgCount1092 sessBaselineMsgs = fullMsgCount
1089 persistMu.Unlock()1093 persistMu.Unlock()
1094+ if iv != nil {
1095+ iv.SetPinnedSkillsPending(false)
1096+ }
1090 // Re-scope the swarm dashboard to the new session so /swarm1097 // Re-scope the swarm dashboard to the new session so /swarm
1091 // only shows agents this session spawned. swarmMgr may be nil1098 // only shows agents this session spawned. swarmMgr may be nil
1092 // here if we haven't reached the construction site yet (it1099 // here if we haven't reached the construction site yet (it
@@ -1223,6 +1230,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1223 if iv != nil {1230 if iv != nil {
1224 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))1231 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome()))
1225 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)1232 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
1233+ iv.SetPinnedSkillsPending(true)
1226 }1234 }
1227 1235
1228 // Re-scope the swarm dashboard to the new session.1236 // Re-scope the swarm dashboard to the new session.
@@ -1457,28 +1465,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1457 }1465 }
1458 _ = MarkChangelogShown(v)1466 _ = MarkChangelogShown(v)
1459 },1467 },
1460- SkillSnapshot: func() []*skills.Skill {1468+ PreloadPinnedSkills: freshSession && !args.NoSkill,
1469+ LoadSkillPins: func(cwd string) (skills.Pins, error) {
1461 if args.NoSkill {1470 if args.NoSkill {
1462- // --no-skill: nothing for the picker to show.1471+ return skills.Pins{}, nil
1463- return nil
1464- }
1465- // Re-discover so the picker reflects edits made during
1466- // the session. Cheap; SKILL.md files are small. Filter
1467- // out built-in skills — they're hidden from user-facing
1468- // surfaces because they're implementation detail; the
1469- // model still sees them through the system-prompt
1470- // manifest and the skill tool.
1471- userHome, _ := os.UserHomeDir()
1472- var sources []skills.Source
1473- if args.WithSkills {
1474- sources = append(sources, skills.SearchSources(ZotHome(), r.CWD, userHome)...)
1475- }
1476- if !args.NoExt || len(args.Exts) > 0 {
1477- extSources, _ := extensions.PlanSkillSources(ZotHome(), r.CWD, args.Exts, !args.NoExt)
1478- sources = append(extSources, sources...)
1479 }1472 }
1480- list, _ := skills.DiscoverSources(sources, true)1473+ return loadSkillPins(cwd)
1481- return skills.VisibleSkills(list)1474+ },
1475+ ToggleSkillPin: toggleSkillPin,
1476+ SkillSnapshot: func() []*skills.Skill {
1477+ return userSkillSnapshot(args)
1482 },1478 },
1483 NoYolo: args.NoYolo,1479 NoYolo: args.NoYolo,
1484 ConfirmGate: confirmGate,1480 ConfirmGate: confirmGate,
@@ -1574,9 +1570,16 @@ func agentSessionsRoot(root string, args Args) string {
1574 // with a nil error if session persistence is disabled. When a session1570 // with a nil error if session persistence is disabled. When a session
1575 // exists, its id is bound onto ag so providers can sticky-route.1571 // exists, its id is bound onto ag so providers can sticky-route.
1576 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {1572 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1573+ s, _, err := openOrCreateSessionState(args, r, ag, version)
1574+ return s, err
1575+}
1576+
1577+// The freshness result distinguishes an empty resumed session from a new one.
1578+func openOrCreateSessionState(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, bool, error) {
1579+ fresh := true
1577 if args.NoSess {1580 if args.NoSess {
1578 setZotSessionEnvironment(r, nil)1581 setZotSessionEnvironment(r, nil)
1579- return nil, nil1582+ return nil, fresh, nil
1580 }1583 }
1581 // Sweep meta-only files left over from older zot versions (and from1584 // Sweep meta-only files left over from older zot versions (and from
1582 // any session that crashed before its first AppendMessage). Cheap;1585 // any session that crashed before its first AppendMessage). Cheap;
@@ -1591,6 +1594,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1591 switch {1594 switch {
1592 case args.Session != "":1595 case args.Session != "":
1593 s, msgs, err = core.OpenSession(args.Session)1596 s, msgs, err = core.OpenSession(args.Session)
1597+ fresh = false
1594 // The swarm-agent child passes a fixed --session path that1598 // The swarm-agent child passes a fixed --session path that
1595 // may not exist yet on first Spawn. Treat ENOENT as "create1599 // may not exist yet on first Spawn. Treat ENOENT as "create
1596 // a fresh session AT THIS PATH" so the conversation actually1600 // a fresh session AT THIS PATH" so the conversation actually
@@ -1601,29 +1605,32 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1601 // paths that already exist on disk.1605 // paths that already exist on disk.
1602 if err != nil && errors.Is(err, os.ErrNotExist) {1606 if err != nil && errors.Is(err, os.ErrNotExist) {
1603 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)1607 s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
1608+ fresh = true
1604 msgs = nil1609 msgs = nil
1605 }1610 }
1606 case args.Continue:1611 case args.Continue:
1607 latest := core.LatestSession(sessionsRoot, args.CWD)1612 latest := core.LatestSession(sessionsRoot, args.CWD)
1608 if latest != "" {1613 if latest != "" {
1609 s, msgs, err = core.OpenSession(latest)1614 s, msgs, err = core.OpenSession(latest)
1615+ fresh = false
1610 }1616 }
1611 case args.Resume:1617 case args.Resume:
1612 picked, perr := pickSession(sessionsRoot, args.CWD)1618 picked, perr := pickSession(sessionsRoot, args.CWD)
1613 if perr != nil {1619 if perr != nil {
1614- return nil, perr1620+ return nil, false, perr
1615 }1621 }
1616 if picked != "" {1622 if picked != "" {
1617 s, msgs, err = core.OpenSession(picked)1623 s, msgs, err = core.OpenSession(picked)
1624+ fresh = false
1618 }1625 }
1619 }1626 }
1620 if err != nil {1627 if err != nil {
1621- return nil, err1628+ return nil, false, err
1622 }1629 }
1623 if s == nil {1630 if s == nil {
1624 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)1631 s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1625 if err != nil {1632 if err != nil {
1626- return nil, err1633+ return nil, false, err
1627 }1634 }
1628 } else {1635 } else {
1629 ag.SetMessages(msgs)1636 ag.SetMessages(msgs)
@@ -1634,7 +1641,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1634 }1641 }
1635 bindAgentSession(ag, s)1642 bindAgentSession(ag, s)
1636 setZotSessionEnvironment(r, s)1643 setZotSessionEnvironment(r, s)
1637- return s, nil1644+ return s, fresh, nil
1638 }1645 }
1639 1646
1640 // setZotSessionEnvironment publishes the non-secret runtime metadata that1647 // setZotSessionEnvironment publishes the non-secret runtime metadata that
M packages/agent/modes/interactive.go +21 -1
@@ -309,6 +309,11 @@ type InteractiveConfig struct {
309309 // so the picker reflects edits made during the session.
310310 SkillSnapshot func() []*skills.Skill
311311
312+ // Pin preferences are owned by the host, not the picker.
313+ LoadSkillPins func(cwd string) (skills.Pins, error)
314+ ToggleSkillPin func(cwd, name string, global bool) error
315+ PreloadPinnedSkills bool
316+
312317 // ChangelogChan, if non-nil, delivers release-notes for the
313318 // current binary version once at startup. Interactive opens a
314319 // dismissible overlay when the channel produces a non-empty
@@ -531,6 +536,9 @@ type Interactive struct {
531536 extPanel *extPanelDialog
532537 llamaConfigured bool
533538
539+ pinnedSkillsPending bool
540+ pinnedSkills []*skills.Skill
541+
534542 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
535543 // via swarm_spawn. Each entry holds the agent + the task text;
536544 // a per-entry goroutine waits on the agent's terminal state. When
@@ -703,6 +711,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
703711 inputHistoryIndex: -1,
704712 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
705713 }
714+ i.SetPinnedSkillsPending(cfg.PreloadPinnedSkills)
706715 var keymapIssues []string
707716 i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
708717 i.reloadErrors = append(i.reloadErrors, keymapIssues...)
@@ -1568,6 +1577,9 @@ func (i *Interactive) redraw() {
15681577 bottom = append(bottom, "")
15691578 }
15701579 bottom = append(bottom, dialog...)
1580+ if len(dialog) == 0 && len(suggest) == 0 {
1581+ bottom = append(bottom, i.pinnedSkillsNotice(cols)...)
1582+ }
15711583 // The swarm dashboard owns the bottom of the screen while it's
15721584 // active: it has its own inline editors for spawn (`n`) and
15731585 // prompt (`p`), so the main input would be a confusing second
@@ -2484,7 +2496,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
24842496 i.invalidate()
24852497 return false
24862498 }
2487- i.skillsDialog.HandleKey(k)
2499+ if !i.handleSkillPinKey(k) {
2500+ i.skillsDialog.HandleKey(k)
2501+ }
24882502 i.invalidate()
24892503 return false
24902504 }
@@ -3204,6 +3218,7 @@ func (i *Interactive) completeStartupPre() {
32043218 // single-threaded. Input entered while resources were reloading wins over the
32053219 // deferred prefill rather than being overwritten.
32063220 func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3221+ i.refreshSkillPins()
32073222 if result.deferred == "" {
32083223 return
32093224 }
@@ -4600,6 +4615,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
46004615 i.reloadErrors = nil
46014616 i.view.InvalidateRenderCache()
46024617 i.mu.Unlock()
4618+ i.SetPinnedSkillsPending(true)
46034619 case "/help":
46044620 i.mu.Lock()
46054621 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
@@ -5281,6 +5297,7 @@ func (i *Interactive) openSkillsDialog() {
52815297 list = i.cfg.SkillSnapshot()
52825298 }
52835299 i.skillsDialog.Open(list)
5300+ i.refreshSkillPins()
52845301 i.invalidate()
52855302 }
52865303
@@ -5989,6 +6006,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
59896006
59906007 ctx, cancel := context.WithCancel(parent)
59916008 i.mu.Lock()
6009+ if !overflowRecoveryAttempted && !i.awaitingStartupPre {
6010+ prompt = i.preloadPinnedPromptLocked(prompt)
6011+ }
59926012 i.busy = true
59936013 i.spin.Start()
59946014 i.cancelTurn = cancel
@@ -309,6 +309,11 @@ type InteractiveConfig struct {
309 // so the picker reflects edits made during the session.309 // so the picker reflects edits made during the session.
310 SkillSnapshot func() []*skills.Skill310 SkillSnapshot func() []*skills.Skill
311 311
312+ // Pin preferences are owned by the host, not the picker.
313+ LoadSkillPins func(cwd string) (skills.Pins, error)
314+ ToggleSkillPin func(cwd, name string, global bool) error
315+ PreloadPinnedSkills bool
316+
312 // ChangelogChan, if non-nil, delivers release-notes for the317 // ChangelogChan, if non-nil, delivers release-notes for the
313 // current binary version once at startup. Interactive opens a318 // current binary version once at startup. Interactive opens a
314 // dismissible overlay when the channel produces a non-empty319 // dismissible overlay when the channel produces a non-empty
@@ -531,6 +536,9 @@ type Interactive struct {
531 extPanel *extPanelDialog536 extPanel *extPanelDialog
532 llamaConfigured bool537 llamaConfigured bool
533 538
539+ pinnedSkillsPending bool
540+ pinnedSkills []*skills.Skill
541+
534 // swarmWatch tracks auto-swarm sub-agents the main agent spawned542 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
535 // via swarm_spawn. Each entry holds the agent + the task text;543 // via swarm_spawn. Each entry holds the agent + the task text;
536 // a per-entry goroutine waits on the agent's terminal state. When544 // a per-entry goroutine waits on the agent's terminal state. When
@@ -703,6 +711,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
703 inputHistoryIndex: -1,711 inputHistoryIndex: -1,
704 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),712 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
705 }713 }
714+ i.SetPinnedSkillsPending(cfg.PreloadPinnedSkills)
706 var keymapIssues []string715 var keymapIssues []string
707 i.keymap, keymapIssues = compileKeymap(cfg.Keymap)716 i.keymap, keymapIssues = compileKeymap(cfg.Keymap)
708 i.reloadErrors = append(i.reloadErrors, keymapIssues...)717 i.reloadErrors = append(i.reloadErrors, keymapIssues...)
@@ -1568,6 +1577,9 @@ func (i *Interactive) redraw() {
1568 bottom = append(bottom, "")1577 bottom = append(bottom, "")
1569 }1578 }
1570 bottom = append(bottom, dialog...)1579 bottom = append(bottom, dialog...)
1580+ if len(dialog) == 0 && len(suggest) == 0 {
1581+ bottom = append(bottom, i.pinnedSkillsNotice(cols)...)
1582+ }
1571 // The swarm dashboard owns the bottom of the screen while it's1583 // The swarm dashboard owns the bottom of the screen while it's
1572 // active: it has its own inline editors for spawn (`n`) and1584 // active: it has its own inline editors for spawn (`n`) and
1573 // prompt (`p`), so the main input would be a confusing second1585 // prompt (`p`), so the main input would be a confusing second
@@ -2484,7 +2496,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2484 i.invalidate()2496 i.invalidate()
2485 return false2497 return false
2486 }2498 }
2487- i.skillsDialog.HandleKey(k)2499+ if !i.handleSkillPinKey(k) {
2500+ i.skillsDialog.HandleKey(k)
2501+ }
2488 i.invalidate()2502 i.invalidate()
2489 return false2503 return false
2490 }2504 }
@@ -3204,6 +3218,7 @@ func (i *Interactive) completeStartupPre() {
3204 // single-threaded. Input entered while resources were reloading wins over the3218 // single-threaded. Input entered while resources were reloading wins over the
3205 // deferred prefill rather than being overwritten.3219 // deferred prefill rather than being overwritten.
3206 func (i *Interactive) applyStartupPreResult(result startupPreResult) {3220 func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3221+ i.refreshSkillPins()
3207 if result.deferred == "" {3222 if result.deferred == "" {
3208 return3223 return
3209 }3224 }
@@ -4600,6 +4615,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4600 i.reloadErrors = nil4615 i.reloadErrors = nil
4601 i.view.InvalidateRenderCache()4616 i.view.InvalidateRenderCache()
4602 i.mu.Unlock()4617 i.mu.Unlock()
4618+ i.SetPinnedSkillsPending(true)
4603 case "/help":4619 case "/help":
4604 i.mu.Lock()4620 i.mu.Lock()
4605 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)4621 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap)
@@ -5281,6 +5297,7 @@ func (i *Interactive) openSkillsDialog() {
5281 list = i.cfg.SkillSnapshot()5297 list = i.cfg.SkillSnapshot()
5282 }5298 }
5283 i.skillsDialog.Open(list)5299 i.skillsDialog.Open(list)
5300+ i.refreshSkillPins()
5284 i.invalidate()5301 i.invalidate()
5285 }5302 }
5286 5303
@@ -5989,6 +6006,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5989 6006
5990 ctx, cancel := context.WithCancel(parent)6007 ctx, cancel := context.WithCancel(parent)
5991 i.mu.Lock()6008 i.mu.Lock()
6009+ if !overflowRecoveryAttempted && !i.awaitingStartupPre {
6010+ prompt = i.preloadPinnedPromptLocked(prompt)
6011+ }
5992 i.busy = true6012 i.busy = true
5993 i.spin.Start()6013 i.spin.Start()
5994 i.cancelTurn = cancel6014 i.cancelTurn = cancel
A packages/agent/modes/skill_pins.go +110 -0
@@ -0,0 +1,110 @@
1+package modes
2+
3+import (
4+ "slices"
5+
6+ "github.com/patriceckhart/zot/packages/agent/skills"
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+// SetPinnedSkillsPending arms preloading for a fresh conversation or disarms
11+// it after a session load. Changing models must not reset this state.
12+func (i *Interactive) SetPinnedSkillsPending(pending bool) {
13+ i.mu.Lock()
14+ i.pinnedSkillsPending = pending && i.cfg.LoadSkillPins != nil
15+ i.pinnedSkills = nil
16+ i.mu.Unlock()
17+ if pending {
18+ i.refreshSkillPins()
19+ }
20+ i.invalidate()
21+}
22+
23+func (i *Interactive) refreshSkillPins() {
24+ i.mu.Lock()
25+ defer i.mu.Unlock()
26+ if i.cfg.LoadSkillPins == nil {
27+ return
28+ }
29+ pins, err := i.cfg.LoadSkillPins(i.cfg.CWD)
30+ i.skillsDialog.pins = pins
31+ i.skillsDialog.canPin = i.cfg.ToggleSkillPin != nil
32+ var warnings []string
33+ if err != nil {
34+ warnings = append(warnings, err.Error())
35+ }
36+ if i.pinnedSkillsPending {
37+ var available []*skills.Skill
38+ if i.cfg.SkillSnapshot != nil {
39+ available = i.cfg.SkillSnapshot()
40+ }
41+ var missing []string
42+ i.pinnedSkills, missing = pins.Resolve(available)
43+ warnings = append(warnings, missing...)
44+ }
45+ for _, warning := range warnings {
46+ if !slices.Contains(i.reloadErrors, warning) {
47+ i.reloadErrors = append(i.reloadErrors, warning)
48+ }
49+ }
50+}
51+
52+func (i *Interactive) handleSkillPinKey(k tui.Key) bool {
53+ if k.Kind != tui.KeyRune || (k.Rune != 'p' && k.Rune != 'g') {
54+ return false
55+ }
56+ i.mu.Lock()
57+ d := i.skillsDialog
58+ if !d.Active() || d.viewing != nil || len(d.skills) == 0 || i.cfg.ToggleSkillPin == nil {
59+ i.mu.Unlock()
60+ return false
61+ }
62+ err := i.cfg.ToggleSkillPin(i.cfg.CWD, d.skills[d.cursor].Name, k.Rune == 'g')
63+ if err != nil {
64+ i.statusErr = "save skill pin: " + err.Error()
65+ i.statusOK = ""
66+ } else {
67+ i.statusErr = ""
68+ i.statusOK = "skill pins updated (apply on the next fresh conversation or /clear)"
69+ }
70+ i.mu.Unlock()
71+ if err == nil {
72+ i.refreshSkillPins()
73+ }
74+ return true
75+}
76+
77+// Caller holds i.mu. Pending skills are folded into the real user message so
78+// normal event delivery and persistence include their bodies exactly once.
79+func (i *Interactive) preloadPinnedPromptLocked(prompt string) string {
80+ if !i.pinnedSkillsPending {
81+ return prompt
82+ }
83+ i.pinnedSkillsPending = false
84+ result := skills.PreloadPrompt(i.pinnedSkills, prompt)
85+ i.pinnedSkills = nil
86+ return result
87+}
88+
89+// Caller holds i.mu. Render a read-only notice, never editor placeholders.
90+func (i *Interactive) pinnedSkillsNotice(cols int) []string {
91+ if !i.pinnedSkillsPending || len(i.pinnedSkills) == 0 || cols < 4 {
92+ return nil
93+ }
94+ names := make([]string, 0, len(i.pinnedSkills))
95+ for _, s := range i.pinnedSkills {
96+ name := s.Name
97+ project := slices.Contains(i.skillsDialog.pins.Project, s.Name)
98+ global := slices.Contains(i.skillsDialog.pins.Global, s.Name)
99+ switch {
100+ case project && global:
101+ name += " (project + global)"
102+ case project:
103+ name += " (project)"
104+ case global:
105+ name += " (global)"
106+ }
107+ names = append(names, name)
108+ }
109+ return tui.RenderPinnedSkills(i.cfg.Theme, names, cols)
110+}
@@ -0,0 +1,110 @@
1+package modes
2+
3+import (
4+ "slices"
5+
6+ "github.com/patriceckhart/zot/packages/agent/skills"
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+// SetPinnedSkillsPending arms preloading for a fresh conversation or disarms
11+// it after a session load. Changing models must not reset this state.
12+func (i *Interactive) SetPinnedSkillsPending(pending bool) {
13+ i.mu.Lock()
14+ i.pinnedSkillsPending = pending && i.cfg.LoadSkillPins != nil
15+ i.pinnedSkills = nil
16+ i.mu.Unlock()
17+ if pending {
18+ i.refreshSkillPins()
19+ }
20+ i.invalidate()
21+}
22+
23+func (i *Interactive) refreshSkillPins() {
24+ i.mu.Lock()
25+ defer i.mu.Unlock()
26+ if i.cfg.LoadSkillPins == nil {
27+ return
28+ }
29+ pins, err := i.cfg.LoadSkillPins(i.cfg.CWD)
30+ i.skillsDialog.pins = pins
31+ i.skillsDialog.canPin = i.cfg.ToggleSkillPin != nil
32+ var warnings []string
33+ if err != nil {
34+ warnings = append(warnings, err.Error())
35+ }
36+ if i.pinnedSkillsPending {
37+ var available []*skills.Skill
38+ if i.cfg.SkillSnapshot != nil {
39+ available = i.cfg.SkillSnapshot()
40+ }
41+ var missing []string
42+ i.pinnedSkills, missing = pins.Resolve(available)
43+ warnings = append(warnings, missing...)
44+ }
45+ for _, warning := range warnings {
46+ if !slices.Contains(i.reloadErrors, warning) {
47+ i.reloadErrors = append(i.reloadErrors, warning)
48+ }
49+ }
50+}
51+
52+func (i *Interactive) handleSkillPinKey(k tui.Key) bool {
53+ if k.Kind != tui.KeyRune || (k.Rune != 'p' && k.Rune != 'g') {
54+ return false
55+ }
56+ i.mu.Lock()
57+ d := i.skillsDialog
58+ if !d.Active() || d.viewing != nil || len(d.skills) == 0 || i.cfg.ToggleSkillPin == nil {
59+ i.mu.Unlock()
60+ return false
61+ }
62+ err := i.cfg.ToggleSkillPin(i.cfg.CWD, d.skills[d.cursor].Name, k.Rune == 'g')
63+ if err != nil {
64+ i.statusErr = "save skill pin: " + err.Error()
65+ i.statusOK = ""
66+ } else {
67+ i.statusErr = ""
68+ i.statusOK = "skill pins updated (apply on the next fresh conversation or /clear)"
69+ }
70+ i.mu.Unlock()
71+ if err == nil {
72+ i.refreshSkillPins()
73+ }
74+ return true
75+}
76+
77+// Caller holds i.mu. Pending skills are folded into the real user message so
78+// normal event delivery and persistence include their bodies exactly once.
79+func (i *Interactive) preloadPinnedPromptLocked(prompt string) string {
80+ if !i.pinnedSkillsPending {
81+ return prompt
82+ }
83+ i.pinnedSkillsPending = false
84+ result := skills.PreloadPrompt(i.pinnedSkills, prompt)
85+ i.pinnedSkills = nil
86+ return result
87+}
88+
89+// Caller holds i.mu. Render a read-only notice, never editor placeholders.
90+func (i *Interactive) pinnedSkillsNotice(cols int) []string {
91+ if !i.pinnedSkillsPending || len(i.pinnedSkills) == 0 || cols < 4 {
92+ return nil
93+ }
94+ names := make([]string, 0, len(i.pinnedSkills))
95+ for _, s := range i.pinnedSkills {
96+ name := s.Name
97+ project := slices.Contains(i.skillsDialog.pins.Project, s.Name)
98+ global := slices.Contains(i.skillsDialog.pins.Global, s.Name)
99+ switch {
100+ case project && global:
101+ name += " (project + global)"
102+ case project:
103+ name += " (project)"
104+ case global:
105+ name += " (global)"
106+ }
107+ names = append(names, name)
108+ }
109+ return tui.RenderPinnedSkills(i.cfg.Theme, names, cols)
110+}
A packages/agent/modes/skill_pins_test.go +224 -0
@@ -0,0 +1,224 @@
1+package modes
2+
3+import (
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+
17+func 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+
25+func 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+
61+func 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+
117+func 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+
140+func 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+
160+func 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+}
@@ -0,0 +1,224 @@
1+package modes
2+
3+import (
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+
17+func 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+
25+func 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+
61+func 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+
117+func 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+
140+func 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+
160+func 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+}
M packages/agent/modes/skills_dialog.go +20 -4
@@ -2,17 +2,19 @@ package modes
22
33 import (
44 "fmt"
5+ "slices"
56 "strings"
67
78 "github.com/patriceckhart/zot/packages/agent/skills"
89 "github.com/patriceckhart/zot/packages/tui"
910 )
1011
11-// skillsDialog lists every discovered skill and lets the user view
12-// the body of one inline. View is read-only — the model loads skills
13-// itself via the `skill` tool. This dialog is for inspection.
12+// skillsDialog lists discovered skills, their pin scopes, and their bodies.
13+// Pin persistence is handled by the interactive host.
1414 type skillsDialog struct {
1515 active bool
16+ pins skills.Pins
17+ canPin bool
1618 skills []*skills.Skill
1719 cursor int
1820 viewing *skills.Skill // when non-nil, render the body instead of the list
@@ -98,6 +100,9 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
98100 }
99101
100102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103+ if d.canPin {
104+ out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))
105+ }
101106 if len(d.skills) == 0 {
102107 out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))
103108 out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
@@ -112,7 +117,18 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
112117 }
113118 for i := start; i < end; i++ {
114119 s := d.skills[i]
115- row := formatSkillRow(s, width-2)
120+ marker := ""
121+ if d.canPin {
122+ project, global := "-", "-"
123+ if slices.Contains(d.pins.Project, s.Name) {
124+ project = "p"
125+ }
126+ if slices.Contains(d.pins.Global, s.Name) {
127+ global = "g"
128+ }
129+ marker = "[" + project + global + "] "
130+ }
131+ row := marker + formatSkillRow(s, width-2-len(marker))
116132 if i == d.cursor {
117133 out = append(out, th.PadHighlight(" "+row, width))
118134 } else {
@@ -2,17 +2,19 @@ package modes
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "slices"
5 "strings"6 "strings"
6 7
7 "github.com/patriceckhart/zot/packages/agent/skills"8 "github.com/patriceckhart/zot/packages/agent/skills"
8 "github.com/patriceckhart/zot/packages/tui"9 "github.com/patriceckhart/zot/packages/tui"
9 )10 )
10 11
11-// skillsDialog lists every discovered skill and lets the user view12+// skillsDialog lists discovered skills, their pin scopes, and their bodies.
12-// the body of one inline. View is read-only — the model loads skills13+// Pin persistence is handled by the interactive host.
13-// itself via the `skill` tool. This dialog is for inspection.
14 type skillsDialog struct {14 type skillsDialog struct {
15 active bool15 active bool
16+ pins skills.Pins
17+ canPin bool
16 skills []*skills.Skill18 skills []*skills.Skill
17 cursor int19 cursor int
18 viewing *skills.Skill // when non-nil, render the body instead of the list20 viewing *skills.Skill // when non-nil, render the body instead of the list
@@ -98,6 +100,9 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
98 }100 }
99 101
100 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103+ if d.canPin {
104+ out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))
105+ }
101 if len(d.skills) == 0 {106 if len(d.skills) == 0 {
102 out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))107 out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))
103 out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))108 out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
@@ -112,7 +117,18 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
112 }117 }
113 for i := start; i < end; i++ {118 for i := start; i < end; i++ {
114 s := d.skills[i]119 s := d.skills[i]
115- row := formatSkillRow(s, width-2)120+ marker := ""
121+ if d.canPin {
122+ project, global := "-", "-"
123+ if slices.Contains(d.pins.Project, s.Name) {
124+ project = "p"
125+ }
126+ if slices.Contains(d.pins.Global, s.Name) {
127+ global = "g"
128+ }
129+ marker = "[" + project + global + "] "
130+ }
131+ row := marker + formatSkillRow(s, width-2-len(marker))
116 if i == d.cursor {132 if i == d.cursor {
117 out = append(out, th.PadHighlight(" "+row, width))133 out = append(out, th.PadHighlight(" "+row, width))
118 } else {134 } else {
A packages/agent/skill_pins.go +156 -0
@@ -0,0 +1,156 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "slices"
11+ "sort"
12+ "sync"
13+
14+ "github.com/patriceckhart/zot/packages/agent/extensions"
15+ "github.com/patriceckhart/zot/packages/agent/skills"
16+)
17+
18+// userSkillSnapshot rediscovers user-facing skills with the same precedence
19+// as agent construction. Built-in implementation skills are not pinnable.
20+func userSkillSnapshot(args Args) []*skills.Skill {
21+ if args.NoSkill {
22+ return nil
23+ }
24+ userHome, _ := os.UserHomeDir()
25+ var sources []skills.Source
26+ if args.WithSkills {
27+ sources = append(sources, skills.SearchSources(ZotHome(), args.CWD, userHome)...)
28+ }
29+ if !args.NoExt || len(args.Exts) > 0 {
30+ extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
31+ sources = append(extSources, sources...)
32+ }
33+ list, _ := skills.DiscoverSources(sources, true)
34+ return skills.VisibleSkills(list)
35+}
36+
37+func preloadSkillPins(args Args, fresh bool, prompt string, diagnostics io.Writer) string {
38+ if !fresh || args.NoSkill {
39+ return prompt
40+ }
41+ pins, err := loadSkillPins(args.CWD)
42+ if err != nil {
43+ fmt.Fprintln(diagnostics, "warning:", err)
44+ return prompt
45+ }
46+ selected, warnings := pins.Resolve(userSkillSnapshot(args))
47+ for _, warning := range warnings {
48+ fmt.Fprintln(diagnostics, "warning:", warning)
49+ }
50+ return skills.PreloadPrompt(selected, prompt)
51+}
52+
53+// Keep preferences local, even for project pins. Never write into a checkout.
54+type skillPinsFile struct {
55+ Global []string `json:"global,omitempty"`
56+ Projects map[string][]string `json:"projects,omitempty"`
57+}
58+
59+var skillPinsMu sync.Mutex
60+
61+func skillPinProject(cwd string) (string, error) {
62+ path, err := filepath.Abs(cwd)
63+ if err != nil {
64+ return "", err
65+ }
66+ // Existing symlink aliases of a directory share their project pins.
67+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
68+ path = resolved
69+ }
70+ return filepath.Clean(path), nil
71+}
72+
73+func readSkillPins() (skillPinsFile, error) {
74+ var pins skillPinsFile
75+ data, err := os.ReadFile(filepath.Join(ZotHome(), "skill-pins.json"))
76+ if errors.Is(err, os.ErrNotExist) {
77+ return pins, nil
78+ }
79+ if err != nil {
80+ return pins, fmt.Errorf("read skill pins: %w", err)
81+ }
82+ // Do not echo malformed file contents in diagnostics.
83+ if json.Unmarshal(data, &pins) != nil {
84+ return skillPinsFile{}, fmt.Errorf("invalid skill-pins.json")
85+ }
86+ return pins, nil
87+}
88+
89+func loadSkillPins(cwd string) (skills.Pins, error) {
90+ skillPinsMu.Lock()
91+ defer skillPinsMu.Unlock()
92+ project, err := skillPinProject(cwd)
93+ if err != nil {
94+ return skills.Pins{}, err
95+ }
96+ pins, err := readSkillPins()
97+ return skills.Pins{Global: pins.Global, Project: pins.Projects[project]}, err
98+}
99+
100+func toggleSkillPin(cwd, name string, global bool) error {
101+ if name == "" {
102+ return fmt.Errorf("skill name is required")
103+ }
104+ skillPinsMu.Lock()
105+ defer skillPinsMu.Unlock()
106+ project, err := skillPinProject(cwd)
107+ if err != nil {
108+ return err
109+ }
110+ pins, err := readSkillPins()
111+ if err != nil {
112+ return err
113+ }
114+ toggle := func(names []string) []string {
115+ if slices.Contains(names, name) {
116+ return slices.DeleteFunc(names, func(n string) bool { return n == name })
117+ }
118+ names = append(names, name)
119+ sort.Strings(names)
120+ return names
121+ }
122+ if global {
123+ pins.Global = toggle(pins.Global)
124+ } else {
125+ if pins.Projects == nil {
126+ pins.Projects = make(map[string][]string)
127+ }
128+ pins.Projects[project] = toggle(pins.Projects[project])
129+ if len(pins.Projects[project]) == 0 {
130+ delete(pins.Projects, project)
131+ }
132+ }
133+ data, err := json.MarshalIndent(pins, "", " ")
134+ if err != nil {
135+ return err
136+ }
137+ if err := os.MkdirAll(ZotHome(), 0o700); err != nil {
138+ return err
139+ }
140+ f, err := os.CreateTemp(ZotHome(), ".skill-pins-*")
141+ if err != nil {
142+ return err
143+ }
144+ defer os.Remove(f.Name())
145+ if _, err := f.Write(append(data, '\n')); err != nil {
146+ f.Close()
147+ return err
148+ }
149+ if err := f.Close(); err != nil {
150+ return err
151+ }
152+ if err := os.Rename(f.Name(), filepath.Join(ZotHome(), "skill-pins.json")); err != nil {
153+ return fmt.Errorf("save skill pins: %w", err)
154+ }
155+ return nil
156+}
@@ -0,0 +1,156 @@
1+package agent
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "slices"
11+ "sort"
12+ "sync"
13+
14+ "github.com/patriceckhart/zot/packages/agent/extensions"
15+ "github.com/patriceckhart/zot/packages/agent/skills"
16+)
17+
18+// userSkillSnapshot rediscovers user-facing skills with the same precedence
19+// as agent construction. Built-in implementation skills are not pinnable.
20+func userSkillSnapshot(args Args) []*skills.Skill {
21+ if args.NoSkill {
22+ return nil
23+ }
24+ userHome, _ := os.UserHomeDir()
25+ var sources []skills.Source
26+ if args.WithSkills {
27+ sources = append(sources, skills.SearchSources(ZotHome(), args.CWD, userHome)...)
28+ }
29+ if !args.NoExt || len(args.Exts) > 0 {
30+ extSources, _ := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
31+ sources = append(extSources, sources...)
32+ }
33+ list, _ := skills.DiscoverSources(sources, true)
34+ return skills.VisibleSkills(list)
35+}
36+
37+func preloadSkillPins(args Args, fresh bool, prompt string, diagnostics io.Writer) string {
38+ if !fresh || args.NoSkill {
39+ return prompt
40+ }
41+ pins, err := loadSkillPins(args.CWD)
42+ if err != nil {
43+ fmt.Fprintln(diagnostics, "warning:", err)
44+ return prompt
45+ }
46+ selected, warnings := pins.Resolve(userSkillSnapshot(args))
47+ for _, warning := range warnings {
48+ fmt.Fprintln(diagnostics, "warning:", warning)
49+ }
50+ return skills.PreloadPrompt(selected, prompt)
51+}
52+
53+// Keep preferences local, even for project pins. Never write into a checkout.
54+type skillPinsFile struct {
55+ Global []string `json:"global,omitempty"`
56+ Projects map[string][]string `json:"projects,omitempty"`
57+}
58+
59+var skillPinsMu sync.Mutex
60+
61+func skillPinProject(cwd string) (string, error) {
62+ path, err := filepath.Abs(cwd)
63+ if err != nil {
64+ return "", err
65+ }
66+ // Existing symlink aliases of a directory share their project pins.
67+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
68+ path = resolved
69+ }
70+ return filepath.Clean(path), nil
71+}
72+
73+func readSkillPins() (skillPinsFile, error) {
74+ var pins skillPinsFile
75+ data, err := os.ReadFile(filepath.Join(ZotHome(), "skill-pins.json"))
76+ if errors.Is(err, os.ErrNotExist) {
77+ return pins, nil
78+ }
79+ if err != nil {
80+ return pins, fmt.Errorf("read skill pins: %w", err)
81+ }
82+ // Do not echo malformed file contents in diagnostics.
83+ if json.Unmarshal(data, &pins) != nil {
84+ return skillPinsFile{}, fmt.Errorf("invalid skill-pins.json")
85+ }
86+ return pins, nil
87+}
88+
89+func loadSkillPins(cwd string) (skills.Pins, error) {
90+ skillPinsMu.Lock()
91+ defer skillPinsMu.Unlock()
92+ project, err := skillPinProject(cwd)
93+ if err != nil {
94+ return skills.Pins{}, err
95+ }
96+ pins, err := readSkillPins()
97+ return skills.Pins{Global: pins.Global, Project: pins.Projects[project]}, err
98+}
99+
100+func toggleSkillPin(cwd, name string, global bool) error {
101+ if name == "" {
102+ return fmt.Errorf("skill name is required")
103+ }
104+ skillPinsMu.Lock()
105+ defer skillPinsMu.Unlock()
106+ project, err := skillPinProject(cwd)
107+ if err != nil {
108+ return err
109+ }
110+ pins, err := readSkillPins()
111+ if err != nil {
112+ return err
113+ }
114+ toggle := func(names []string) []string {
115+ if slices.Contains(names, name) {
116+ return slices.DeleteFunc(names, func(n string) bool { return n == name })
117+ }
118+ names = append(names, name)
119+ sort.Strings(names)
120+ return names
121+ }
122+ if global {
123+ pins.Global = toggle(pins.Global)
124+ } else {
125+ if pins.Projects == nil {
126+ pins.Projects = make(map[string][]string)
127+ }
128+ pins.Projects[project] = toggle(pins.Projects[project])
129+ if len(pins.Projects[project]) == 0 {
130+ delete(pins.Projects, project)
131+ }
132+ }
133+ data, err := json.MarshalIndent(pins, "", " ")
134+ if err != nil {
135+ return err
136+ }
137+ if err := os.MkdirAll(ZotHome(), 0o700); err != nil {
138+ return err
139+ }
140+ f, err := os.CreateTemp(ZotHome(), ".skill-pins-*")
141+ if err != nil {
142+ return err
143+ }
144+ defer os.Remove(f.Name())
145+ if _, err := f.Write(append(data, '\n')); err != nil {
146+ f.Close()
147+ return err
148+ }
149+ if err := f.Close(); err != nil {
150+ return err
151+ }
152+ if err := os.Rename(f.Name(), filepath.Join(ZotHome(), "skill-pins.json")); err != nil {
153+ return fmt.Errorf("save skill pins: %w", err)
154+ }
155+ return nil
156+}
A packages/agent/skill_pins_test.go +183 -0
@@ -0,0 +1,183 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "slices"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestSkillPinsPersistIndependentScopes(t *testing.T) {
17+ t.Setenv("ZOT_HOME", t.TempDir())
18+ a, b := t.TempDir(), t.TempDir()
19+ for _, pin := range []struct {
20+ cwd, name string
21+ global bool
22+ }{
23+ {a, "review", false}, {a, "review", true}, {b, "other", false},
24+ } {
25+ if err := toggleSkillPin(pin.cwd, pin.name, pin.global); err != nil {
26+ t.Fatal(err)
27+ }
28+ }
29+ if err := toggleSkillPin(a, "review", false); err != nil {
30+ t.Fatal(err)
31+ }
32+ pins, err := loadSkillPins(a)
33+ if err != nil || len(pins.Project) != 0 || !slices.Equal(pins.Global, []string{"review"}) {
34+ t.Fatalf("pins = %+v, %v", pins, err)
35+ }
36+ pins, err = loadSkillPins(b)
37+ if err != nil || !slices.Equal(pins.Project, []string{"other"}) || !slices.Equal(pins.Global, []string{"review"}) {
38+ t.Fatalf("pins = %+v, %v", pins, err)
39+ }
40+ info, err := os.Stat(filepath.Join(ZotHome(), "skill-pins.json"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
45+ t.Fatalf("mode = %o", info.Mode().Perm())
46+ }
47+ entries, err := os.ReadDir(a)
48+ if err != nil || len(entries) != 0 {
49+ t.Fatalf("project was modified: %v, %v", entries, err)
50+ }
51+}
52+
53+func TestSkillPinsDirectoryAliases(t *testing.T) {
54+ t.Setenv("ZOT_HOME", t.TempDir())
55+ root := t.TempDir()
56+ target, alias := filepath.Join(root, "target"), filepath.Join(root, "alias")
57+ if err := os.Mkdir(target, 0o700); err != nil {
58+ t.Fatal(err)
59+ }
60+ if err := os.Symlink(target, alias); err != nil {
61+ t.Skipf("symlink unavailable: %v", err)
62+ }
63+ if err := toggleSkillPin(target, "review", false); err != nil {
64+ t.Fatal(err)
65+ }
66+ pins, err := loadSkillPins(alias)
67+ if err != nil || !slices.Equal(pins.Project, []string{"review"}) {
68+ t.Fatalf("alias pins = %+v, %v", pins, err)
69+ }
70+}
71+
72+func TestSkillPinsRejectCorruptFileWithoutOverwriting(t *testing.T) {
73+ t.Setenv("ZOT_HOME", t.TempDir())
74+ path := filepath.Join(ZotHome(), "skill-pins.json")
75+ original := []byte(`{"global":["review"],"projects":42}`)
76+ if err := os.WriteFile(path, original, 0o600); err != nil {
77+ t.Fatal(err)
78+ }
79+ pins, err := loadSkillPins(t.TempDir())
80+ if err == nil || len(pins.Global) != 0 {
81+ t.Fatalf("pins = %+v, err = %v", pins, err)
82+ }
83+ if err := toggleSkillPin(t.TempDir(), "new", true); err == nil {
84+ t.Fatal("corrupt file overwritten")
85+ }
86+ got, err := os.ReadFile(path)
87+ if err != nil || !bytes.Equal(got, original) {
88+ t.Fatalf("file changed: %q, %v", got, err)
89+ }
90+}
91+
92+func TestPreloadSkillPinsFreshOnlyAndNoSkill(t *testing.T) {
93+ t.Setenv("ZOT_HOME", t.TempDir())
94+ t.Setenv("HOME", t.TempDir())
95+ cwd := t.TempDir()
96+ dir := filepath.Join(cwd, ".zot", "skills", "review")
97+ if err := os.MkdirAll(dir, 0o700); err != nil {
98+ t.Fatal(err)
99+ }
100+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\ndisable-model-invocation: true\n---\nInspect carefully.\n"), 0o600); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := toggleSkillPin(cwd, "review", true); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := toggleSkillPin(cwd, "review", false); err != nil {
107+ t.Fatal(err)
108+ }
109+ if err := toggleSkillPin(cwd, "missing", false); err != nil {
110+ t.Fatal(err)
111+ }
112+ for _, tt := range []struct {
113+ name string
114+ fresh, disabled bool
115+ }{
116+ {"fresh", true, false}, {"resumed", false, false}, {"disabled", true, true},
117+ } {
118+ t.Run(tt.name, func(t *testing.T) {
119+ var diagnostics bytes.Buffer
120+ args := Args{CWD: cwd, WithSkills: true, NoExt: true, NoSkill: tt.disabled}
121+ got := preloadSkillPins(args, tt.fresh, "question", &diagnostics)
122+ if tt.fresh && !tt.disabled {
123+ if strings.Count(got, "# Skill: review") != 1 || !strings.Contains(got, "Inspect carefully.") {
124+ t.Fatal(got)
125+ }
126+ if !strings.Contains(diagnostics.String(), "missing") {
127+ t.Fatal("missing warning")
128+ }
129+ } else if got != "question" || diagnostics.Len() != 0 {
130+ t.Fatalf("prompt = %q, warnings = %q", got, diagnostics.String())
131+ }
132+ })
133+ }
134+}
135+
136+func TestOpenSessionFreshness(t *testing.T) {
137+ t.Setenv("ZOT_HOME", t.TempDir())
138+ isolateSessionEnvironment(t)
139+ cwd := t.TempDir()
140+ r := Resolved{Provider: "test", Model: "test"}
141+ ag := core.NewAgent(nil, "test", "", nil)
142+ args := Args{CWD: cwd, Continue: true}
143+ sess, fresh, err := openOrCreateSessionState(args, r, ag, "test")
144+ if err != nil || !fresh {
145+ t.Fatalf("continue without history: fresh=%v, err=%v", fresh, err)
146+ }
147+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "previous"}}}); err != nil {
148+ t.Fatal(err)
149+ }
150+ sess.Close()
151+ sess, fresh, err = openOrCreateSessionState(args, r, ag, "test")
152+ if err != nil || fresh {
153+ t.Fatalf("continue with history: fresh=%v, err=%v", fresh, err)
154+ }
155+ sess.Close()
156+ // A persisted session compacted to no messages is still a resumed session.
157+ path := filepath.Join(t.TempDir(), "empty.jsonl")
158+ empty, err := core.NewSessionAtPath(path, cwd, "test", "test", "test")
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ if err := empty.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
163+ t.Fatal(err)
164+ }
165+ if err := empty.AppendCompaction(nil); err != nil {
166+ t.Fatal(err)
167+ }
168+ defer empty.Close()
169+ sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: path}, r, ag, "test")
170+ if err != nil || fresh || len(ag.Messages()) != 0 {
171+ t.Fatalf("empty resume: fresh=%v, err=%v", fresh, err)
172+ }
173+ sess.Close()
174+ sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: filepath.Join(t.TempDir(), "new.jsonl")}, r, ag, "test")
175+ if err != nil || !fresh {
176+ t.Fatalf("new explicit path: fresh=%v, err=%v", fresh, err)
177+ }
178+ sess.Close()
179+ _, fresh, err = openOrCreateSessionState(Args{NoSess: true}, r, ag, "test")
180+ if err != nil || !fresh {
181+ t.Fatalf("no persistence: fresh=%v, err=%v", fresh, err)
182+ }
183+}
@@ -0,0 +1,183 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "slices"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+)
15+
16+func TestSkillPinsPersistIndependentScopes(t *testing.T) {
17+ t.Setenv("ZOT_HOME", t.TempDir())
18+ a, b := t.TempDir(), t.TempDir()
19+ for _, pin := range []struct {
20+ cwd, name string
21+ global bool
22+ }{
23+ {a, "review", false}, {a, "review", true}, {b, "other", false},
24+ } {
25+ if err := toggleSkillPin(pin.cwd, pin.name, pin.global); err != nil {
26+ t.Fatal(err)
27+ }
28+ }
29+ if err := toggleSkillPin(a, "review", false); err != nil {
30+ t.Fatal(err)
31+ }
32+ pins, err := loadSkillPins(a)
33+ if err != nil || len(pins.Project) != 0 || !slices.Equal(pins.Global, []string{"review"}) {
34+ t.Fatalf("pins = %+v, %v", pins, err)
35+ }
36+ pins, err = loadSkillPins(b)
37+ if err != nil || !slices.Equal(pins.Project, []string{"other"}) || !slices.Equal(pins.Global, []string{"review"}) {
38+ t.Fatalf("pins = %+v, %v", pins, err)
39+ }
40+ info, err := os.Stat(filepath.Join(ZotHome(), "skill-pins.json"))
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
45+ t.Fatalf("mode = %o", info.Mode().Perm())
46+ }
47+ entries, err := os.ReadDir(a)
48+ if err != nil || len(entries) != 0 {
49+ t.Fatalf("project was modified: %v, %v", entries, err)
50+ }
51+}
52+
53+func TestSkillPinsDirectoryAliases(t *testing.T) {
54+ t.Setenv("ZOT_HOME", t.TempDir())
55+ root := t.TempDir()
56+ target, alias := filepath.Join(root, "target"), filepath.Join(root, "alias")
57+ if err := os.Mkdir(target, 0o700); err != nil {
58+ t.Fatal(err)
59+ }
60+ if err := os.Symlink(target, alias); err != nil {
61+ t.Skipf("symlink unavailable: %v", err)
62+ }
63+ if err := toggleSkillPin(target, "review", false); err != nil {
64+ t.Fatal(err)
65+ }
66+ pins, err := loadSkillPins(alias)
67+ if err != nil || !slices.Equal(pins.Project, []string{"review"}) {
68+ t.Fatalf("alias pins = %+v, %v", pins, err)
69+ }
70+}
71+
72+func TestSkillPinsRejectCorruptFileWithoutOverwriting(t *testing.T) {
73+ t.Setenv("ZOT_HOME", t.TempDir())
74+ path := filepath.Join(ZotHome(), "skill-pins.json")
75+ original := []byte(`{"global":["review"],"projects":42}`)
76+ if err := os.WriteFile(path, original, 0o600); err != nil {
77+ t.Fatal(err)
78+ }
79+ pins, err := loadSkillPins(t.TempDir())
80+ if err == nil || len(pins.Global) != 0 {
81+ t.Fatalf("pins = %+v, err = %v", pins, err)
82+ }
83+ if err := toggleSkillPin(t.TempDir(), "new", true); err == nil {
84+ t.Fatal("corrupt file overwritten")
85+ }
86+ got, err := os.ReadFile(path)
87+ if err != nil || !bytes.Equal(got, original) {
88+ t.Fatalf("file changed: %q, %v", got, err)
89+ }
90+}
91+
92+func TestPreloadSkillPinsFreshOnlyAndNoSkill(t *testing.T) {
93+ t.Setenv("ZOT_HOME", t.TempDir())
94+ t.Setenv("HOME", t.TempDir())
95+ cwd := t.TempDir()
96+ dir := filepath.Join(cwd, ".zot", "skills", "review")
97+ if err := os.MkdirAll(dir, 0o700); err != nil {
98+ t.Fatal(err)
99+ }
100+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: review\ndescription: Review things.\ndisable-model-invocation: true\n---\nInspect carefully.\n"), 0o600); err != nil {
101+ t.Fatal(err)
102+ }
103+ if err := toggleSkillPin(cwd, "review", true); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := toggleSkillPin(cwd, "review", false); err != nil {
107+ t.Fatal(err)
108+ }
109+ if err := toggleSkillPin(cwd, "missing", false); err != nil {
110+ t.Fatal(err)
111+ }
112+ for _, tt := range []struct {
113+ name string
114+ fresh, disabled bool
115+ }{
116+ {"fresh", true, false}, {"resumed", false, false}, {"disabled", true, true},
117+ } {
118+ t.Run(tt.name, func(t *testing.T) {
119+ var diagnostics bytes.Buffer
120+ args := Args{CWD: cwd, WithSkills: true, NoExt: true, NoSkill: tt.disabled}
121+ got := preloadSkillPins(args, tt.fresh, "question", &diagnostics)
122+ if tt.fresh && !tt.disabled {
123+ if strings.Count(got, "# Skill: review") != 1 || !strings.Contains(got, "Inspect carefully.") {
124+ t.Fatal(got)
125+ }
126+ if !strings.Contains(diagnostics.String(), "missing") {
127+ t.Fatal("missing warning")
128+ }
129+ } else if got != "question" || diagnostics.Len() != 0 {
130+ t.Fatalf("prompt = %q, warnings = %q", got, diagnostics.String())
131+ }
132+ })
133+ }
134+}
135+
136+func TestOpenSessionFreshness(t *testing.T) {
137+ t.Setenv("ZOT_HOME", t.TempDir())
138+ isolateSessionEnvironment(t)
139+ cwd := t.TempDir()
140+ r := Resolved{Provider: "test", Model: "test"}
141+ ag := core.NewAgent(nil, "test", "", nil)
142+ args := Args{CWD: cwd, Continue: true}
143+ sess, fresh, err := openOrCreateSessionState(args, r, ag, "test")
144+ if err != nil || !fresh {
145+ t.Fatalf("continue without history: fresh=%v, err=%v", fresh, err)
146+ }
147+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "previous"}}}); err != nil {
148+ t.Fatal(err)
149+ }
150+ sess.Close()
151+ sess, fresh, err = openOrCreateSessionState(args, r, ag, "test")
152+ if err != nil || fresh {
153+ t.Fatalf("continue with history: fresh=%v, err=%v", fresh, err)
154+ }
155+ sess.Close()
156+ // A persisted session compacted to no messages is still a resumed session.
157+ path := filepath.Join(t.TempDir(), "empty.jsonl")
158+ empty, err := core.NewSessionAtPath(path, cwd, "test", "test", "test")
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ if err := empty.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}); err != nil {
163+ t.Fatal(err)
164+ }
165+ if err := empty.AppendCompaction(nil); err != nil {
166+ t.Fatal(err)
167+ }
168+ defer empty.Close()
169+ sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: path}, r, ag, "test")
170+ if err != nil || fresh || len(ag.Messages()) != 0 {
171+ t.Fatalf("empty resume: fresh=%v, err=%v", fresh, err)
172+ }
173+ sess.Close()
174+ sess, fresh, err = openOrCreateSessionState(Args{CWD: cwd, Session: filepath.Join(t.TempDir(), "new.jsonl")}, r, ag, "test")
175+ if err != nil || !fresh {
176+ t.Fatalf("new explicit path: fresh=%v, err=%v", fresh, err)
177+ }
178+ sess.Close()
179+ _, fresh, err = openOrCreateSessionState(Args{NoSess: true}, r, ag, "test")
180+ if err != nil || !fresh {
181+ t.Fatalf("no persistence: fresh=%v, err=%v", fresh, err)
182+ }
183+}
A packages/agent/skills/pins.go +53 -0
@@ -0,0 +1,53 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+)
8+
9+// Pins records independent global and project selections by discovered name.
10+type Pins struct {
11+ Global []string `json:"global,omitempty"`
12+ Project []string `json:"project,omitempty"`
13+}
14+
15+// Resolve returns each available pinned skill once, in name order.
16+func (p Pins) Resolve(available []*Skill) ([]*Skill, []string) {
17+ names := make(map[string]bool)
18+ for _, name := range append(append([]string(nil), p.Global...), p.Project...) {
19+ names[name] = true
20+ }
21+ sorted := make([]string, 0, len(names))
22+ for name := range names {
23+ sorted = append(sorted, name)
24+ }
25+ sort.Strings(sorted)
26+ var selected []*Skill
27+ var warnings []string
28+ for _, name := range sorted {
29+ if s := FindByName(available, name); s != nil {
30+ selected = append(selected, s)
31+ } else {
32+ warnings = append(warnings, fmt.Sprintf("pinned skill %q is unavailable", name))
33+ }
34+ }
35+ return selected, warnings
36+}
37+
38+// PreloadPrompt includes pinned instructions with the first real request.
39+// It creates no synthetic tool calls and does not start a model turn.
40+func PreloadPrompt(selected []*Skill, request string) string {
41+ if len(selected) == 0 {
42+ return request
43+ }
44+ var b strings.Builder
45+ b.WriteString("Pinned skills for this conversation. Apply these instructions where relevant to the user's requests.\n\n")
46+ for _, s := range selected {
47+ b.WriteString(InvocationPrompt(s, ""))
48+ b.WriteString("\n\n")
49+ }
50+ b.WriteString("---\n\nUser request:\n")
51+ b.WriteString(request)
52+ return b.String()
53+}
@@ -0,0 +1,53 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+)
8+
9+// Pins records independent global and project selections by discovered name.
10+type Pins struct {
11+ Global []string `json:"global,omitempty"`
12+ Project []string `json:"project,omitempty"`
13+}
14+
15+// Resolve returns each available pinned skill once, in name order.
16+func (p Pins) Resolve(available []*Skill) ([]*Skill, []string) {
17+ names := make(map[string]bool)
18+ for _, name := range append(append([]string(nil), p.Global...), p.Project...) {
19+ names[name] = true
20+ }
21+ sorted := make([]string, 0, len(names))
22+ for name := range names {
23+ sorted = append(sorted, name)
24+ }
25+ sort.Strings(sorted)
26+ var selected []*Skill
27+ var warnings []string
28+ for _, name := range sorted {
29+ if s := FindByName(available, name); s != nil {
30+ selected = append(selected, s)
31+ } else {
32+ warnings = append(warnings, fmt.Sprintf("pinned skill %q is unavailable", name))
33+ }
34+ }
35+ return selected, warnings
36+}
37+
38+// PreloadPrompt includes pinned instructions with the first real request.
39+// It creates no synthetic tool calls and does not start a model turn.
40+func PreloadPrompt(selected []*Skill, request string) string {
41+ if len(selected) == 0 {
42+ return request
43+ }
44+ var b strings.Builder
45+ b.WriteString("Pinned skills for this conversation. Apply these instructions where relevant to the user's requests.\n\n")
46+ for _, s := range selected {
47+ b.WriteString(InvocationPrompt(s, ""))
48+ b.WriteString("\n\n")
49+ }
50+ b.WriteString("---\n\nUser request:\n")
51+ b.WriteString(request)
52+ return b.String()
53+}
A packages/agent/skills/pins_test.go +33 -0
@@ -0,0 +1,33 @@
1+package skills
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinsResolveUnionAndMissing(t *testing.T) {
10+ a := &Skill{Name: "a", Body: "first"}
11+ b := &Skill{Name: "ext:b", Body: "second", DisableModelInvocation: true}
12+ pins := Pins{Global: []string{"ext:b", "a", "missing"}, Project: []string{"a", "ext:b"}}
13+ selected, warnings := pins.Resolve([]*Skill{b, a})
14+ if len(selected) != 2 || selected[0] != a || selected[1] != b {
15+ t.Fatalf("selected = %#v", selected)
16+ }
17+ if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
18+ t.Fatalf("warnings = %v", warnings)
19+ }
20+}
21+
22+func TestPreloadPromptPreservesSkillLocationAndRequest(t *testing.T) {
23+ s := &Skill{Name: "review", Body: "Inspect carefully.", Path: filepath.Join("skills", "review", "SKILL.md")}
24+ got := PreloadPrompt([]*Skill{s}, "my request")
25+ for _, want := range []string{"# Skill: review", "Inspect carefully.", "Skill directory: " + filepath.Dir(s.Path), "User request:\nmy request"} {
26+ if !strings.Contains(got, want) {
27+ t.Errorf("missing %q in %q", want, got)
28+ }
29+ }
30+ if got := PreloadPrompt(nil, "unchanged"); got != "unchanged" {
31+ t.Fatal(got)
32+ }
33+}
@@ -0,0 +1,33 @@
1+package skills
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinsResolveUnionAndMissing(t *testing.T) {
10+ a := &Skill{Name: "a", Body: "first"}
11+ b := &Skill{Name: "ext:b", Body: "second", DisableModelInvocation: true}
12+ pins := Pins{Global: []string{"ext:b", "a", "missing"}, Project: []string{"a", "ext:b"}}
13+ selected, warnings := pins.Resolve([]*Skill{b, a})
14+ if len(selected) != 2 || selected[0] != a || selected[1] != b {
15+ t.Fatalf("selected = %#v", selected)
16+ }
17+ if len(warnings) != 1 || !strings.Contains(warnings[0], "missing") {
18+ t.Fatalf("warnings = %v", warnings)
19+ }
20+}
21+
22+func TestPreloadPromptPreservesSkillLocationAndRequest(t *testing.T) {
23+ s := &Skill{Name: "review", Body: "Inspect carefully.", Path: filepath.Join("skills", "review", "SKILL.md")}
24+ got := PreloadPrompt([]*Skill{s}, "my request")
25+ for _, want := range []string{"# Skill: review", "Inspect carefully.", "Skill directory: " + filepath.Dir(s.Path), "User request:\nmy request"} {
26+ if !strings.Contains(got, want) {
27+ t.Errorf("missing %q in %q", want, got)
28+ }
29+ }
30+ if got := PreloadPrompt(nil, "unchanged"); got != "unchanged" {
31+ t.Fatal(got)
32+ }
33+}
A packages/tui/pinned_skills.go +20 -0
@@ -0,0 +1,20 @@
1+package tui
2+
3+import (
4+ "strings"
5+
6+ "github.com/mattn/go-runewidth"
7+)
8+
9+// RenderPinnedSkills matches the startup resource sections, with an accent
10+// heading and indented, muted names. A leading blank separates it from chat.
11+func RenderPinnedSkills(th Theme, names []string, width int) []string {
12+ if len(names) == 0 || width < 4 {
13+ return nil
14+ }
15+ out := []string{"", th.FG256(th.Accent, runewidth.Truncate("[Pinned skills]", width, "..."))}
16+ for _, line := range wrapLine(strings.Join(names, ", "), width-2, "") {
17+ out = append(out, th.FG256(th.Muted, " "+line))
18+ }
19+ return out
20+}
@@ -0,0 +1,20 @@
1+package tui
2+
3+import (
4+ "strings"
5+
6+ "github.com/mattn/go-runewidth"
7+)
8+
9+// RenderPinnedSkills matches the startup resource sections, with an accent
10+// heading and indented, muted names. A leading blank separates it from chat.
11+func RenderPinnedSkills(th Theme, names []string, width int) []string {
12+ if len(names) == 0 || width < 4 {
13+ return nil
14+ }
15+ out := []string{"", th.FG256(th.Accent, runewidth.Truncate("[Pinned skills]", width, "..."))}
16+ for _, line := range wrapLine(strings.Join(names, ", "), width-2, "") {
17+ out = append(out, th.FG256(th.Muted, " "+line))
18+ }
19+ return out
20+}
A packages/tui/pinned_skills_test.go +36 -0
@@ -0,0 +1,36 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinnedSkillsMatchesResourceSections(t *testing.T) {
10+ th := Theme{Accent: 75, Muted: 245}
11+ got := RenderPinnedSkills(th, []string{"review", "vscode"}, 80)
12+ want := []string{"", th.FG256(th.Accent, "[Pinned skills]"), th.FG256(th.Muted, " review, vscode")}
13+ if !reflect.DeepEqual(got, want) {
14+ t.Fatalf("rows = %q, want %q", got, want)
15+ }
16+ if got := RenderPinnedSkills(th, nil, 80); len(got) != 0 {
17+ t.Fatalf("empty pins rendered rows: %q", got)
18+ }
19+}
20+
21+func TestPinnedSkillsWrapNamesToTerminalWidth(t *testing.T) {
22+ for _, width := range []int{1, 4, 12, 30, 80} {
23+ rows := RenderPinnedSkills(Theme{}, []string{strings.Repeat("界", 20), "review", "vscode"}, width)
24+ for _, row := range rows {
25+ if got := visibleWidth(row); got > width {
26+ t.Fatalf("row width %d exceeds %d: %q", got, width, row)
27+ }
28+ }
29+ if width >= 4 {
30+ body := strings.Join(rows[2:], "")
31+ if !strings.Contains(body, "vscode") && width >= 12 {
32+ t.Fatalf("last skill was truncated: %q", rows)
33+ }
34+ }
35+ }
36+}
@@ -0,0 +1,36 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestPinnedSkillsMatchesResourceSections(t *testing.T) {
10+ th := Theme{Accent: 75, Muted: 245}
11+ got := RenderPinnedSkills(th, []string{"review", "vscode"}, 80)
12+ want := []string{"", th.FG256(th.Accent, "[Pinned skills]"), th.FG256(th.Muted, " review, vscode")}
13+ if !reflect.DeepEqual(got, want) {
14+ t.Fatalf("rows = %q, want %q", got, want)
15+ }
16+ if got := RenderPinnedSkills(th, nil, 80); len(got) != 0 {
17+ t.Fatalf("empty pins rendered rows: %q", got)
18+ }
19+}
20+
21+func TestPinnedSkillsWrapNamesToTerminalWidth(t *testing.T) {
22+ for _, width := range []int{1, 4, 12, 30, 80} {
23+ rows := RenderPinnedSkills(Theme{}, []string{strings.Repeat("界", 20), "review", "vscode"}, width)
24+ for _, row := range rows {
25+ if got := visibleWidth(row); got > width {
26+ t.Fatalf("row width %d exceeds %d: %q", got, width, row)
27+ }
28+ }
29+ if width >= 4 {
30+ body := strings.Join(rows[2:], "")
31+ if !strings.Contains(body, "vscode") && width >= 12 {
32+ t.Fatalf("last skill was truncated: %q", rows)
33+ }
34+ }
35+ }
36+}

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