mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
* feat(skills): make skill invocation deterministic and discoverable
Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:
User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
after builtins and user commands, the skill body is inlined as the
prompt with the typed args appended, and submitted through the same
launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
Labels are honest: a skill shadowed by a builtin, alias, user command,
or a case-colliding sibling is listed bare with a '(via skill tool)'
note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
same loader dispatch uses, so mid-session installs appear and shadowed
or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
guards: mid-run submissions queue the EXPANDED prompt (the queue flush
submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
with plugin skill roots — the same set the skill tool and the system
prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
bodies.
Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
only discovery surface, and the old cap made skills past ~6 invisible —
invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
the trigger conditions ('use when…'); cutting them is what stopped the
model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
matching skill FIRST, don't substitute your own approach.
* feat(tui): /skills opens a searchable skill picker
Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.
* feat(tui): ask-first guard for bare skill invocations
A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.
* fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions
- The compaction-in-flight guard for skill/user-command invocations fired
after the dispatch path had already cleared the composer, so the typed
"/name args" was lost and had to be retyped; thread the raw invocation
through and restore it into the composer (the plain-prompt guard keeps
the composer intact by firing before the clear). The picker path has no
typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
Name rather than the fallback Desc wording.
* chore: ignore zero-skills local build artifact
A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
65 lines
2.1 KiB
Go
65 lines
2.1 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/Gitlawb/zero/internal/usercommands"
|
|
)
|
|
|
|
// handleUserCommand resolves a "/name args" that wasn't a builtin command
|
|
// against the file-sourced user commands (.zero/commands/<name>.md). When a
|
|
// match is found it expands the command's template with the args and launches a
|
|
// normal agent turn, returning handled=true. handled=false means no user
|
|
// command matched, so the caller falls through to "unknown command".
|
|
//
|
|
// raw is the full slash input as parseCommand captured it for commandUnknown
|
|
// (e.g. "/release v1.2"), so we re-split it here rather than thread the parsed
|
|
// name/args through the builtin parser (which doesn't know about file commands).
|
|
func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
|
|
name, args := splitUserCommand(raw)
|
|
if name == "" {
|
|
return m, nil, false
|
|
}
|
|
cmd, ok := m.lookupUserCommand(name)
|
|
if !ok {
|
|
return m, nil, false
|
|
}
|
|
prompt := usercommands.Expand(cmd.Template, args)
|
|
if strings.TrimSpace(prompt) == "" {
|
|
return m, nil, false
|
|
}
|
|
// Same run-state guards as a plain prompt: a user command invoked mid-run is
|
|
// queued (as its expanded prompt), not raced into a second concurrent turn.
|
|
return m.launchOrDeferExpandedPrompt(raw, prompt)
|
|
}
|
|
|
|
// lookupUserCommand returns the loaded user command with the given (lowercased)
|
|
// name. Linear scan — the set is small (a handful of files).
|
|
func (m model) lookupUserCommand(name string) (usercommands.Command, bool) {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
for _, cmd := range m.userCommands {
|
|
if cmd.Name == name {
|
|
return cmd, true
|
|
}
|
|
}
|
|
return usercommands.Command{}, false
|
|
}
|
|
|
|
// splitUserCommand splits "/name rest of args" into the bare name (no leading
|
|
// slash, lowercased) and the trailing argument string.
|
|
func splitUserCommand(raw string) (string, string) {
|
|
raw = strings.TrimSpace(raw)
|
|
if !strings.HasPrefix(raw, "/") {
|
|
return "", ""
|
|
}
|
|
fields := strings.Fields(raw)
|
|
if len(fields) == 0 {
|
|
return "", ""
|
|
}
|
|
name := strings.ToLower(strings.TrimPrefix(fields[0], "/"))
|
|
args := strings.TrimSpace(raw[len(fields[0]):])
|
|
return name, args
|
|
}
|