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 | |||
| 477 | 477 | ||
| 478 | 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 | 486 | ### `/compact` | |
| 481 | 487 | ||
| 482 | 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. | |
| @@ -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 | |||
| 89 | 89 | description and source path. Press enter on a row to view the full | |
| 90 | 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 | 150 | ## Invoking skills | |
| 93 | 151 | ||
| 94 | 152 | 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 full | 89 | 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 skills | 150 | ## Invoking skills | ||
| 93 | 151 | ||||
| 94 | For normal skills, the system prompt tells the model the skill names and | 152 | 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 { | |||
| 428 | 428 | ||
| 429 | 429 | ag := r.NewAgent() | |
| 430 | 430 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 431 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 431 | + | sess, fresh, _ := openOrCreateSessionState(args, r, ag, version) | |
| 432 | 432 | defer sess.Close() | |
| 433 | 433 | ||
| 434 | 434 | piped, _ := readAllStdin() | |
| @@ -442,6 +442,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 442 | 442 | return err | |
| 443 | 443 | } | |
| 444 | 444 | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 445 | + | prompt = preloadSkillPins(args, fresh, prompt, os.Stderr) | |
| 445 | 446 | started := time.Now() | |
| 446 | 447 | usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | |
| 447 | 448 | elapsed := time.Since(started) | |
| @@ -468,7 +469,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 468 | 469 | ||
| 469 | 470 | ag := r.NewAgent() | |
| 470 | 471 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 471 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 472 | + | sess, fresh, _ := openOrCreateSessionState(args, r, ag, version) | |
| 472 | 473 | defer sess.Close() | |
| 473 | 474 | ||
| 474 | 475 | piped, _ := readAllStdin() | |
| @@ -485,6 +486,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 485 | 486 | } | |
| 486 | 487 | finishPre() | |
| 487 | 488 | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 489 | + | prompt = preloadSkillPins(args, fresh, prompt, os.Stderr) | |
| 488 | 490 | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 489 | 491 | WriteNewTranscript(ag, sess, start) | |
| 490 | 492 | return err | |
| @@ -547,7 +549,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 547 | 549 | ||
| 548 | 550 | ag := r.NewAgent() | |
| 549 | 551 | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 550 | - | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 552 | + | sess, fresh, _ := openOrCreateSessionState(args, r, ag, version) | |
| 551 | 553 | defer sess.Close() | |
| 552 | 554 | ||
| 553 | 555 | piped, _ := readAllStdin() | |
| @@ -566,6 +568,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 566 | 568 | return err | |
| 567 | 569 | } | |
| 568 | 570 | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 571 | + | prompt = preloadSkillPins(args, fresh, prompt, os.Stderr) | |
| 569 | 572 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | |
| 570 | 573 | WriteNewTranscript(ag, sess, start) | |
| 571 | 574 | return err | |
| @@ -949,6 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 949 | 952 | }) | |
| 950 | 953 | ||
| 951 | 954 | var sess *core.Session | |
| 955 | + | freshSession := true | |
| 952 | 956 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 953 | 957 | // persistMu guards sess + sessBaselineMsgs against concurrent access | |
| 954 | 958 | // 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 | 961 | // races with a finishing turn could double-write or lose messages. | |
| 958 | 962 | var persistMu sync.Mutex | |
| 959 | 963 | if ag != nil { | |
| 960 | - | sess, _ = openOrCreateSession(args, r, ag, version) | |
| 964 | + | sess, freshSession, _ = openOrCreateSessionState(args, r, ag, version) | |
| 961 | 965 | if ag != nil { | |
| 962 | 966 | sessBaselineMsgs = len(ag.Messages()) | |
| 963 | 967 | } | |
| @@ -1087,6 +1091,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1087 | 1091 | // the hydrated tail. | |
| 1088 | 1092 | sessBaselineMsgs = fullMsgCount | |
| 1089 | 1093 | persistMu.Unlock() | |
| 1094 | + | if iv != nil { | |
| 1095 | + | iv.SetPinnedSkillsPending(false) | |
| 1096 | + | } | |
| 1090 | 1097 | // Re-scope the swarm dashboard to the new session so /swarm | |
| 1091 | 1098 | // only shows agents this session spawned. swarmMgr may be nil | |
| 1092 | 1099 | // 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 | 1230 | if iv != nil { | |
| 1224 | 1231 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZotHome())) | |
| 1225 | 1232 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | |
| 1233 | + | iv.SetPinnedSkillsPending(true) | |
| 1226 | 1234 | } | |
| 1227 | 1235 | ||
| 1228 | 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 | 1466 | _ = MarkChangelogShown(v) | |
| 1459 | 1467 | }, | |
| 1460 | - | SkillSnapshot: func() []*skills.Skill { | |
| 1468 | + | PreloadPinnedSkills: freshSession && !args.NoSkill, | |
| 1469 | + | LoadSkillPins: func(cwd string) (skills.Pins, error) { | |
| 1461 | 1470 | 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 | |
| 1479 | 1472 | } | |
| 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) | |
| 1482 | 1478 | }, | |
| 1483 | 1479 | NoYolo: args.NoYolo, | |
| 1484 | 1480 | ConfirmGate: confirmGate, | |
| @@ -1574,9 +1570,16 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 1574 | 1570 | // with a nil error if session persistence is disabled. When a session | |
| 1575 | 1571 | // exists, its id is bound onto ag so providers can sticky-route. | |
| 1576 | 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 | 1580 | if args.NoSess { | |
| 1578 | 1581 | setZotSessionEnvironment(r, nil) | |
| 1579 | - | return nil, nil | |
| 1582 | + | return nil, fresh, nil | |
| 1580 | 1583 | } | |
| 1581 | 1584 | // Sweep meta-only files left over from older zot versions (and from | |
| 1582 | 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 | 1594 | switch { | |
| 1592 | 1595 | case args.Session != "": | |
| 1593 | 1596 | s, msgs, err = core.OpenSession(args.Session) | |
| 1597 | + | fresh = false | |
| 1594 | 1598 | // The swarm-agent child passes a fixed --session path that | |
| 1595 | 1599 | // may not exist yet on first Spawn. Treat ENOENT as "create | |
| 1596 | 1600 | // 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 | 1605 | // paths that already exist on disk. | |
| 1602 | 1606 | if err != nil && errors.Is(err, os.ErrNotExist) { | |
| 1603 | 1607 | s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version) | |
| 1608 | + | fresh = true | |
| 1604 | 1609 | msgs = nil | |
| 1605 | 1610 | } | |
| 1606 | 1611 | case args.Continue: | |
| 1607 | 1612 | latest := core.LatestSession(sessionsRoot, args.CWD) | |
| 1608 | 1613 | if latest != "" { | |
| 1609 | 1614 | s, msgs, err = core.OpenSession(latest) | |
| 1615 | + | fresh = false | |
| 1610 | 1616 | } | |
| 1611 | 1617 | case args.Resume: | |
| 1612 | 1618 | picked, perr := pickSession(sessionsRoot, args.CWD) | |
| 1613 | 1619 | if perr != nil { | |
| 1614 | - | return nil, perr | |
| 1620 | + | return nil, false, perr | |
| 1615 | 1621 | } | |
| 1616 | 1622 | if picked != "" { | |
| 1617 | 1623 | s, msgs, err = core.OpenSession(picked) | |
| 1624 | + | fresh = false | |
| 1618 | 1625 | } | |
| 1619 | 1626 | } | |
| 1620 | 1627 | if err != nil { | |
| 1621 | - | return nil, err | |
| 1628 | + | return nil, false, err | |
| 1622 | 1629 | } | |
| 1623 | 1630 | if s == nil { | |
| 1624 | 1631 | s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version) | |
| 1625 | 1632 | if err != nil { | |
| 1626 | - | return nil, err | |
| 1633 | + | return nil, false, err | |
| 1627 | 1634 | } | |
| 1628 | 1635 | } else { | |
| 1629 | 1636 | ag.SetMessages(msgs) | |
| @@ -1634,7 +1641,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1634 | 1641 | } | |
| 1635 | 1642 | bindAgentSession(ag, s) | |
| 1636 | 1643 | setZotSessionEnvironment(r, s) | |
| 1637 | - | return s, nil | |
| 1644 | + | return s, fresh, nil | |
| 1638 | 1645 | } | |
| 1639 | 1646 | ||
| 1640 | 1647 | // 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 err | 442 | 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 err | 492 | 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 err | 568 | 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 err | 574 | return err | ||
| @@ -949,6 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 949 | }) | 952 | }) | ||
| 950 | 953 | ||||
| 951 | var sess *core.Session | 954 | var sess *core.Session | ||
| 955 | + | freshSession := true | |||
| 952 | var sessBaselineMsgs int // messages already on disk when current session opened | 956 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 953 | // persistMu guards sess + sessBaselineMsgs against concurrent access | 957 | // persistMu guards sess + sessBaselineMsgs against concurrent access | ||
| 954 | // from the agent loop's per-message persistence hook (runs on the | 958 | // 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.Mutex | 962 | 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 = fullMsgCount | 1092 | 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 /swarm | 1097 | // Re-scope the swarm dashboard to the new session so /swarm | ||
| 1091 | // only shows agents this session spawned. swarmMgr may be nil | 1098 | // only shows agents this session spawned. swarmMgr may be nil | ||
| 1092 | // here if we haven't reached the construction site yet (it | 1099 | // 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 session | 1570 | // 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, nil | 1582 | + | return nil, fresh, nil |
| 1580 | } | 1583 | } | ||
| 1581 | // Sweep meta-only files left over from older zot versions (and from | 1584 | // 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 that | 1598 | // The swarm-agent child passes a fixed --session path that | ||
| 1595 | // may not exist yet on first Spawn. Treat ENOENT as "create | 1599 | // may not exist yet on first Spawn. Treat ENOENT as "create | ||
| 1596 | // a fresh session AT THIS PATH" so the conversation actually | 1600 | // 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 = nil | 1609 | 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, perr | 1620 | + | 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, err | 1628 | + | 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, err | 1633 | + | 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, nil | 1644 | + | return s, fresh, nil |
| 1638 | } | 1645 | } | ||
| 1639 | 1646 | ||||
| 1640 | // setZotSessionEnvironment publishes the non-secret runtime metadata that | 1647 | // setZotSessionEnvironment publishes the non-secret runtime metadata that | ||
M packages/agent/modes/interactive.go +21 -1
| @@ -309,6 +309,11 @@ type InteractiveConfig struct { | |||
| 309 | 309 | // so the picker reflects edits made during the session. | |
| 310 | 310 | 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 | 317 | // ChangelogChan, if non-nil, delivers release-notes for the | |
| 313 | 318 | // current binary version once at startup. Interactive opens a | |
| 314 | 319 | // dismissible overlay when the channel produces a non-empty | |
| @@ -531,6 +536,9 @@ type Interactive struct { | |||
| 531 | 536 | extPanel *extPanelDialog | |
| 532 | 537 | llamaConfigured bool | |
| 533 | 538 | ||
| 539 | + | pinnedSkillsPending bool | |
| 540 | + | pinnedSkills []*skills.Skill | |
| 541 | + | ||
| 534 | 542 | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | |
| 535 | 543 | // via swarm_spawn. Each entry holds the agent + the task text; | |
| 536 | 544 | // a per-entry goroutine waits on the agent's terminal state. When | |
| @@ -703,6 +711,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 703 | 711 | inputHistoryIndex: -1, | |
| 704 | 712 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 705 | 713 | } | |
| 714 | + | i.SetPinnedSkillsPending(cfg.PreloadPinnedSkills) | |
| 706 | 715 | var keymapIssues []string | |
| 707 | 716 | i.keymap, keymapIssues = compileKeymap(cfg.Keymap) | |
| 708 | 717 | i.reloadErrors = append(i.reloadErrors, keymapIssues...) | |
| @@ -1568,6 +1577,9 @@ func (i *Interactive) redraw() { | |||
| 1568 | 1577 | bottom = append(bottom, "") | |
| 1569 | 1578 | } | |
| 1570 | 1579 | bottom = append(bottom, dialog...) | |
| 1580 | + | if len(dialog) == 0 && len(suggest) == 0 { | |
| 1581 | + | bottom = append(bottom, i.pinnedSkillsNotice(cols)...) | |
| 1582 | + | } | |
| 1571 | 1583 | // The swarm dashboard owns the bottom of the screen while it's | |
| 1572 | 1584 | // active: it has its own inline editors for spawn (`n`) and | |
| 1573 | 1585 | // 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 | 2496 | i.invalidate() | |
| 2485 | 2497 | return false | |
| 2486 | 2498 | } | |
| 2487 | - | i.skillsDialog.HandleKey(k) | |
| 2499 | + | if !i.handleSkillPinKey(k) { | |
| 2500 | + | i.skillsDialog.HandleKey(k) | |
| 2501 | + | } | |
| 2488 | 2502 | i.invalidate() | |
| 2489 | 2503 | return false | |
| 2490 | 2504 | } | |
| @@ -3204,6 +3218,7 @@ func (i *Interactive) completeStartupPre() { | |||
| 3204 | 3218 | // single-threaded. Input entered while resources were reloading wins over the | |
| 3205 | 3219 | // deferred prefill rather than being overwritten. | |
| 3206 | 3220 | func (i *Interactive) applyStartupPreResult(result startupPreResult) { | |
| 3221 | + | i.refreshSkillPins() | |
| 3207 | 3222 | if result.deferred == "" { | |
| 3208 | 3223 | return | |
| 3209 | 3224 | } | |
| @@ -4600,6 +4615,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4600 | 4615 | i.reloadErrors = nil | |
| 4601 | 4616 | i.view.InvalidateRenderCache() | |
| 4602 | 4617 | i.mu.Unlock() | |
| 4618 | + | i.SetPinnedSkillsPending(true) | |
| 4603 | 4619 | case "/help": | |
| 4604 | 4620 | i.mu.Lock() | |
| 4605 | 4621 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured, i.keymap) | |
| @@ -5281,6 +5297,7 @@ func (i *Interactive) openSkillsDialog() { | |||
| 5281 | 5297 | list = i.cfg.SkillSnapshot() | |
| 5282 | 5298 | } | |
| 5283 | 5299 | i.skillsDialog.Open(list) | |
| 5300 | + | i.refreshSkillPins() | |
| 5284 | 5301 | i.invalidate() | |
| 5285 | 5302 | } | |
| 5286 | 5303 | ||
| @@ -5989,6 +6006,9 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5989 | 6006 | ||
| 5990 | 6007 | ctx, cancel := context.WithCancel(parent) | |
| 5991 | 6008 | i.mu.Lock() | |
| 6009 | + | if !overflowRecoveryAttempted && !i.awaitingStartupPre { | |
| 6010 | + | prompt = i.preloadPinnedPromptLocked(prompt) | |
| 6011 | + | } | |
| 5992 | 6012 | i.busy = true | |
| 5993 | 6013 | i.spin.Start() | |
| 5994 | 6014 | 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.Skill | 310 | 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 the | 317 | // ChangelogChan, if non-nil, delivers release-notes for the | ||
| 313 | // current binary version once at startup. Interactive opens a | 318 | // current binary version once at startup. Interactive opens a | ||
| 314 | // dismissible overlay when the channel produces a non-empty | 319 | // dismissible overlay when the channel produces a non-empty | ||
| @@ -531,6 +536,9 @@ type Interactive struct { | |||||
| 531 | extPanel *extPanelDialog | 536 | extPanel *extPanelDialog | ||
| 532 | llamaConfigured bool | 537 | llamaConfigured bool | ||
| 533 | 538 | ||||
| 539 | + | pinnedSkillsPending bool | |||
| 540 | + | pinnedSkills []*skills.Skill | |||
| 541 | + | ||||
| 534 | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | 542 | // 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. When | 544 | // 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 []string | 715 | 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's | 1583 | // The swarm dashboard owns the bottom of the screen while it's | ||
| 1572 | // active: it has its own inline editors for spawn (`n`) and | 1584 | // active: it has its own inline editors for spawn (`n`) and | ||
| 1573 | // prompt (`p`), so the main input would be a confusing second | 1585 | // 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 false | 2497 | 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 false | 2503 | return false | ||
| 2490 | } | 2504 | } | ||
| @@ -3204,6 +3218,7 @@ func (i *Interactive) completeStartupPre() { | |||||
| 3204 | // single-threaded. Input entered while resources were reloading wins over the | 3218 | // 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 | return | 3223 | return | ||
| 3209 | } | 3224 | } | ||
| @@ -4600,6 +4615,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4600 | i.reloadErrors = nil | 4615 | 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 = true | 6012 | i.busy = true | ||
| 5993 | i.spin.Start() | 6013 | i.spin.Start() | ||
| 5994 | i.cancelTurn = cancel | 6014 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "slices" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/patriceckhart/zot/packages/agent/skills" | |
| 8 | 9 | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 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. | |
| 14 | 14 | type skillsDialog struct { | |
| 15 | 15 | active bool | |
| 16 | + | pins skills.Pins | |
| 17 | + | canPin bool | |
| 16 | 18 | skills []*skills.Skill | |
| 17 | 19 | cursor int | |
| 18 | 20 | 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 | 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 | 106 | if len(d.skills) == 0 { | |
| 102 | 107 | out = append(out, " "+th.FG256(th.Muted, "no user skills loaded")) | |
| 103 | 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 | 118 | for i := start; i < end; i++ { | |
| 114 | 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 | 132 | if i == d.cursor { | |
| 117 | 133 | out = append(out, th.PadHighlight(" "+row, width)) | |
| 118 | 134 | } 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 view | 12 | + | // skillsDialog lists discovered skills, their pin scopes, and their bodies. |
| 12 | - | // the body of one inline. View is read-only — the model loads skills | 13 | + | // 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 bool | 15 | active bool | ||
| 16 | + | pins skills.Pins | |||
| 17 | + | canPin bool | |||
| 16 | skills []*skills.Skill | 18 | skills []*skills.Skill | ||
| 17 | cursor int | 19 | cursor int | ||
| 18 | viewing *skills.Skill // when non-nil, render the body instead of the list | 20 | 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 | + | } | |||