Files
zero/internal/tui/user_commands.go
KRATOS 284a6c86cb Skills: deterministic invocation — /<skill-name>, searchable /skills picker, discovery-budget fix (#520)
* 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.
2026-07-05 21:14:53 +05:30

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
}