mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40: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.
717 lines
20 KiB
Go
717 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/providercatalog"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/zerocommands"
|
|
)
|
|
|
|
func (m model) toolsText() string {
|
|
registered := m.registeredTools()
|
|
count := len(registered)
|
|
if len(registered) == 0 {
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Tools",
|
|
Summary: []string{"0 registered", "no tools available"},
|
|
Sections: []commandCardSection{{
|
|
Title: "Registry",
|
|
Fields: []commandField{
|
|
{Key: "registered", Value: "0"},
|
|
},
|
|
}},
|
|
Actions: []string{"/mcp manage servers", "/permissions manage access"},
|
|
})
|
|
}
|
|
|
|
names := make([]string, 0, count)
|
|
for _, tool := range registered {
|
|
names = append(names, tool.Name())
|
|
}
|
|
sort.Strings(names)
|
|
available := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
available = append(available, commandBullet(name))
|
|
}
|
|
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Tools",
|
|
Summary: []string{fmt.Sprintf("%d registered", count), "registered catalog"},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "Registry",
|
|
Fields: []commandField{
|
|
{Key: "registered", Value: fmt.Sprint(count)},
|
|
},
|
|
},
|
|
{
|
|
Title: "Available",
|
|
Lines: available,
|
|
},
|
|
},
|
|
Actions: []string{"/mcp manage servers", "/permissions manage access"},
|
|
})
|
|
}
|
|
|
|
func (m *model) mcpText() string {
|
|
width := 0
|
|
if m.width > 0 {
|
|
width = chatWidth(m.width)
|
|
}
|
|
return renderMCPView(m.mcpViewState(), width)
|
|
}
|
|
|
|
func (m *model) refreshMCPViewState() {
|
|
m.mcpViewStateCache = BuildMCPViewState(MCPStateOptions{
|
|
Config: m.mcpConfig,
|
|
Registry: m.registry,
|
|
PermissionStore: m.mcpPermissionStore,
|
|
PermissionMode: string(m.permissionMode),
|
|
TokenStore: m.mcpTokenStore,
|
|
})
|
|
m.mcpViewStateReady = true
|
|
}
|
|
|
|
func (m *model) mcpViewState() MCPViewState {
|
|
if m.mcpViewStateReady {
|
|
return m.mcpViewStateCache
|
|
}
|
|
// Older tests may construct a zero-value model; keep that path useful, while
|
|
// production refreshes the cache before any MCP view can render.
|
|
m.refreshMCPViewState()
|
|
return m.mcpViewStateCache
|
|
}
|
|
|
|
func (m model) startMCPTranscriptCommand(args string) (model, tea.Cmd) {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: m.mcpText()})
|
|
return m, nil
|
|
}
|
|
parsedArgs, err := splitMCPCommandArgs(args)
|
|
if err != nil {
|
|
text := strings.Join([]string{
|
|
"MCP action failed",
|
|
err.Error(),
|
|
"",
|
|
m.mcpText(),
|
|
}, "\n")
|
|
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
|
|
return m, nil
|
|
}
|
|
return m.startMCPCommand(mcpCommandRequest{origin: mcpCommandOriginTranscript, raw: args, args: parsedArgs})
|
|
}
|
|
|
|
func (m model) startMCPCommand(request mcpCommandRequest) (model, tea.Cmd) {
|
|
if m.mcpCommand == nil {
|
|
result := MCPCommandResult{
|
|
ExitCode: 1,
|
|
Error: "MCP action unavailable",
|
|
Config: m.mcpConfig,
|
|
}
|
|
return m.applyMCPCommandResultMessage(mcpCommandResultMsg{request: request, result: result}), nil
|
|
}
|
|
m.cancelMCPCommand()
|
|
ctx := m.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
m.mcpCommandSeq++
|
|
request.id = m.mcpCommandSeq
|
|
request.args = append([]string{}, request.args...)
|
|
m.mcpCommandCancel = cancel
|
|
runner := m.mcpCommand
|
|
return m, func() tea.Msg {
|
|
return mcpCommandResultMsg{
|
|
request: request,
|
|
result: runner(ctx, request.args),
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *model) cancelMCPCommand() {
|
|
if m.mcpCommandCancel != nil {
|
|
m.mcpCommandCancel()
|
|
m.mcpCommandCancel = nil
|
|
m.mcpCommandSeq++
|
|
}
|
|
}
|
|
|
|
func (m model) applyMCPCommandResultMessage(msg mcpCommandResultMsg) model {
|
|
if msg.request.id != 0 && msg.request.id != m.mcpCommandSeq {
|
|
return m
|
|
}
|
|
m.mcpCommandCancel = nil
|
|
switch msg.request.origin {
|
|
case mcpCommandOriginManager:
|
|
text := ""
|
|
m, text = m.applyMCPCommandResult(strings.Join(msg.request.args, " "), msg.result)
|
|
m.mcpManager = &mcpManagerState{selected: msg.request.managerSelected, query: msg.request.managerQuery}
|
|
if items := m.mcpManagerItems(); len(items) > 0 {
|
|
m.mcpManager.selected = clampInt(m.mcpManager.selected, 0, len(items)-1)
|
|
}
|
|
if text != "" {
|
|
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
|
|
}
|
|
case mcpCommandOriginWizard:
|
|
m = m.applyMCPAddWizardSaveResult(msg.result, msg.request.wizardDisabled)
|
|
default:
|
|
text := ""
|
|
m, text = m.applyMCPCommandResult(msg.request.raw, msg.result)
|
|
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) applyMCPCommandResult(args string, result MCPCommandResult) (model, string) {
|
|
if result.ExitCode != 0 || strings.TrimSpace(result.Error) != "" {
|
|
message := strings.TrimSpace(result.Error)
|
|
if message == "" {
|
|
message = strings.TrimSpace(result.Output)
|
|
}
|
|
if message == "" {
|
|
message = "MCP command failed"
|
|
}
|
|
return m, strings.Join([]string{
|
|
"MCP action failed",
|
|
message,
|
|
"",
|
|
m.mcpText(),
|
|
}, "\n")
|
|
}
|
|
if len(result.Config.Servers) > 0 || len(m.mcpConfig.Servers) > 0 {
|
|
m.mcpConfig = result.Config
|
|
m.refreshMCPViewState()
|
|
}
|
|
output := strings.TrimSpace(result.Output)
|
|
if output == "" {
|
|
output = "zero mcp " + args
|
|
}
|
|
return m, strings.Join([]string{
|
|
"MCP action complete",
|
|
output,
|
|
"",
|
|
m.mcpText(),
|
|
}, "\n")
|
|
}
|
|
|
|
func splitMCPCommandArgs(args string) ([]string, error) {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
return nil, nil
|
|
}
|
|
out := []string{}
|
|
var current strings.Builder
|
|
var quote rune
|
|
hasToken := false
|
|
runes := []rune(args)
|
|
for index := 0; index < len(runes); index++ {
|
|
r := runes[index]
|
|
if quote != 0 {
|
|
if r == quote {
|
|
quote = 0
|
|
hasToken = true
|
|
continue
|
|
}
|
|
if r == '\\' && index+1 < len(runes) && runes[index+1] == quote {
|
|
index++
|
|
current.WriteRune(runes[index])
|
|
hasToken = true
|
|
continue
|
|
}
|
|
current.WriteRune(r)
|
|
hasToken = true
|
|
continue
|
|
}
|
|
switch {
|
|
case r == '\'' || r == '"':
|
|
quote = r
|
|
hasToken = true
|
|
case r == '\\' && index+1 < len(runes) && (runes[index+1] == '\'' || runes[index+1] == '"' || strings.TrimSpace(string(runes[index+1])) == ""):
|
|
index++
|
|
current.WriteRune(runes[index])
|
|
hasToken = true
|
|
case strings.TrimSpace(string(r)) == "":
|
|
if hasToken {
|
|
out = append(out, current.String())
|
|
current.Reset()
|
|
hasToken = false
|
|
}
|
|
default:
|
|
current.WriteRune(r)
|
|
hasToken = true
|
|
}
|
|
}
|
|
if quote != 0 {
|
|
return nil, fmt.Errorf("unterminated quote in MCP command")
|
|
}
|
|
if hasToken {
|
|
out = append(out, current.String())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m model) permissionsText() string {
|
|
if m.sandboxStore == nil {
|
|
return m.permissionsTextWithStore(nil)
|
|
}
|
|
return m.permissionsTextWithStore(m.sandboxStore)
|
|
}
|
|
|
|
func (m model) permissionsTextWithStore(store grantLister) string {
|
|
mode := string(m.permissionMode)
|
|
if store == nil {
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Permissions",
|
|
Summary: []string{mode + " permissions", "grants unavailable"},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "State",
|
|
Fields: []commandField{
|
|
{Key: "mode", Value: mode},
|
|
},
|
|
},
|
|
{
|
|
Title: "Grants",
|
|
Lines: []string{"persistent grants: unavailable"},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
grants, err := store.List()
|
|
if err != nil {
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Permissions",
|
|
Summary: []string{mode + " permissions", "grants error"},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "State",
|
|
Fields: []commandField{
|
|
{Key: "mode", Value: mode},
|
|
},
|
|
},
|
|
{
|
|
Title: "Grants",
|
|
Lines: []string{"error: " + err.Error()},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
prefixes := []sandbox.CommandPrefixGrant{}
|
|
if prefixStore, ok := store.(commandPrefixGrantLister); ok {
|
|
var prefixErr error
|
|
prefixes, prefixErr = prefixStore.ListCommandPrefixes()
|
|
if prefixErr != nil {
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Permissions",
|
|
Summary: []string{mode + " permissions", "grants error"},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "State",
|
|
Fields: []commandField{
|
|
{Key: "mode", Value: mode},
|
|
},
|
|
},
|
|
{
|
|
Title: "Grants",
|
|
Lines: []string{"error: " + prefixErr.Error()},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
snapshots := zerocommands.SandboxGrantSnapshots(grants)
|
|
grantRows := []commandRow{}
|
|
if len(snapshots) == 0 && len(prefixes) == 0 {
|
|
grantRows = append(grantRows, commandRow{Text: "none"})
|
|
} else {
|
|
for _, grant := range snapshots {
|
|
line := fmt.Sprintf("%s [%s]", grant.ToolName, grant.Decision)
|
|
if grant.ApprovedAt != "" {
|
|
line += " approved " + grant.ApprovedAt
|
|
}
|
|
if grant.Reason != "" {
|
|
line += " - " + grant.Reason
|
|
}
|
|
grantRows = append(grantRows, commandRow{Text: line})
|
|
}
|
|
for _, grant := range prefixes {
|
|
line := fmt.Sprintf("%s `%s` [command-prefix]", grant.ToolName, strings.Join(grant.Prefix, " "))
|
|
if grant.ApprovedAt != "" {
|
|
line += " approved " + grant.ApprovedAt
|
|
}
|
|
if grant.Reason != "" {
|
|
line += " - " + grant.Reason
|
|
}
|
|
grantRows = append(grantRows, commandRow{Text: line})
|
|
}
|
|
}
|
|
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Permissions",
|
|
Summary: []string{mode + " permissions", formatGrantCount(len(snapshots) + len(prefixes))},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "State",
|
|
Fields: []commandField{
|
|
{Key: "mode", Value: mode},
|
|
},
|
|
},
|
|
{
|
|
Title: "Grants",
|
|
Rows: grantRows,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// grantLister is the subset of sandbox.GrantStore used by permissionsText().
|
|
// It exists to let tests inject error-stub stores without reaching for a real
|
|
// filesystem path.
|
|
type grantLister interface {
|
|
List() ([]sandbox.Grant, error)
|
|
}
|
|
|
|
type commandPrefixGrantLister interface {
|
|
ListCommandPrefixes() ([]sandbox.CommandPrefixGrant, error)
|
|
}
|
|
|
|
func formatGrantCount(count int) string {
|
|
if count == 0 {
|
|
return "no persistent grants"
|
|
}
|
|
if count == 1 {
|
|
return "1 persistent grant"
|
|
}
|
|
return fmt.Sprintf("%d persistent grants", count)
|
|
}
|
|
|
|
func (m model) providerText() string {
|
|
profileLines := []string{
|
|
"provider: " + displayValue(m.providerName, "none"),
|
|
"model: " + displayValue(m.modelName, "none"),
|
|
}
|
|
if !config.HasProviderProfile(m.providerProfile) {
|
|
profileLines = append(profileLines, "profile: not configured")
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Provider",
|
|
Status: commandStatusWarning,
|
|
Sections: []commandSection{
|
|
{Title: "Active", Lines: profileLines},
|
|
{Title: "Next actions", Lines: []string{
|
|
"zero providers catalog",
|
|
"zero providers setup openai --set-active",
|
|
"zero providers add openai --api-key-env OPENAI_API_KEY --set-active",
|
|
}},
|
|
},
|
|
})
|
|
}
|
|
|
|
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
|
|
profileLines = append(profileLines,
|
|
"active: "+boolText(snapshot.Active),
|
|
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
|
|
"api model: "+displayValue(snapshot.APIModel, "unknown"),
|
|
"base url: "+displayValue(snapshot.BaseURL, "default"),
|
|
"api key: "+apiKeyState(snapshot.APIKeySet),
|
|
)
|
|
if snapshot.Message != "" {
|
|
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
|
|
}
|
|
|
|
status := commandStatusOK
|
|
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
|
|
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
|
|
status = commandStatusWarning
|
|
}
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Provider",
|
|
Status: status,
|
|
Sections: []commandSection{
|
|
{Title: "Active", Lines: profileLines},
|
|
{Title: "Next actions", Lines: actionLines},
|
|
},
|
|
})
|
|
}
|
|
|
|
func providerNextActionLines(profile config.ProviderProfile, snapshot zerocommands.ProviderSnapshot, activeName string) []string {
|
|
providerName := firstProviderDisplayValue(snapshot.Name, activeName, profile.Name, providerSetupCatalogID(profile, snapshot.ProviderKind), "openai")
|
|
setupID := providerSetupCatalogID(profile, snapshot.ProviderKind)
|
|
lines := []string{}
|
|
if providerCredentialRequired(profile, snapshot.ProviderKind) && !providerProfileHasCredential(profile) {
|
|
if envName := providerCredentialEnvName(profile, snapshot.ProviderKind); envName != "" {
|
|
lines = append(lines,
|
|
"set "+envName+" in your environment",
|
|
"zero providers add "+setupID+" --api-key-env "+envName+" --set-active",
|
|
)
|
|
} else {
|
|
lines = append(lines, "set provider credentials in your environment")
|
|
}
|
|
}
|
|
return append(lines,
|
|
"zero providers check "+providerName+" --connectivity",
|
|
"zero providers catalog",
|
|
"zero providers setup "+setupID+" --set-active",
|
|
)
|
|
}
|
|
|
|
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
|
return profile.HasConfiguredCredential()
|
|
}
|
|
|
|
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
|
|
if descriptor, ok := providerCatalogDescriptor(profile); ok {
|
|
return descriptor.RequiresAuth
|
|
}
|
|
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
|
|
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible, config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat, config.ProviderKindGoogle:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func providerCredentialEnvName(profile config.ProviderProfile, providerKind string) string {
|
|
if envName := strings.TrimSpace(profile.APIKeyEnv); envName != "" {
|
|
return envName
|
|
}
|
|
if descriptor, ok := providerCatalogDescriptor(profile); ok && len(descriptor.AuthEnvVars) > 0 {
|
|
return descriptor.AuthEnvVars[0]
|
|
}
|
|
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
|
|
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
|
return "OPENAI_API_KEY"
|
|
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
|
return "ANTHROPIC_API_KEY"
|
|
case config.ProviderKindGoogle:
|
|
return "GEMINI_API_KEY"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func providerSetupCatalogID(profile config.ProviderProfile, providerKind string) string {
|
|
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
|
return catalogID
|
|
}
|
|
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
|
|
case config.ProviderKindOpenAI:
|
|
return "openai"
|
|
case config.ProviderKindAnthropic:
|
|
return "anthropic"
|
|
case config.ProviderKindGoogle:
|
|
return "google"
|
|
case config.ProviderKindOpenAICompatible:
|
|
return "custom-openai-compatible"
|
|
case config.ProviderKindAnthropicCompat:
|
|
return "custom-anthropic-compatible"
|
|
default:
|
|
return firstProviderDisplayValue(profile.Name, "openai")
|
|
}
|
|
}
|
|
|
|
func providerCatalogDescriptor(profile config.ProviderProfile) (providercatalog.Descriptor, bool) {
|
|
catalogID := strings.TrimSpace(profile.CatalogID)
|
|
if catalogID == "" {
|
|
return providercatalog.Descriptor{}, false
|
|
}
|
|
descriptor, err := providercatalog.Require(catalogID)
|
|
if err != nil {
|
|
return providercatalog.Descriptor{}, false
|
|
}
|
|
return descriptor, true
|
|
}
|
|
|
|
func firstProviderDisplayValue(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m model) modelText(args string) string {
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Model",
|
|
Status: commandStatusOK,
|
|
Sections: []commandSection{{
|
|
Title: "Active",
|
|
Lines: []string{
|
|
"model: " + displayValue(m.modelName, "none"),
|
|
"provider: " + displayValue(m.providerName, "none"),
|
|
"effort: " + m.effortDisplay(),
|
|
},
|
|
}},
|
|
Hints: []string{"use /model list to inspect models or /model <id> to switch this TUI session"},
|
|
})
|
|
}
|
|
|
|
// avgTurnLatencyText reports the session's rolling average turn wall-time for
|
|
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
|
|
// a turn has completed.
|
|
func (m model) avgTurnLatencyText() string {
|
|
if m.turnLatencyCount == 0 {
|
|
return "n/a"
|
|
}
|
|
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
|
|
if m.turnTTFTCount > 0 {
|
|
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
|
|
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
|
|
}
|
|
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
|
|
}
|
|
|
|
func (m model) contextText() string {
|
|
toolCount := len(m.registeredTools())
|
|
return renderCommandCardTranscript(commandCard{
|
|
Title: "Context",
|
|
Summary: []string{
|
|
"go runtime",
|
|
string(m.permissionMode) + " permissions",
|
|
pluralizeCount(toolCount, "tool", "tools"),
|
|
},
|
|
Sections: []commandCardSection{
|
|
{
|
|
Title: "Runtime",
|
|
Fields: []commandField{
|
|
{Key: "cwd", Value: displayValue(m.cwd, "unknown")},
|
|
{Key: "provider", Value: displayValue(m.providerName, "none")},
|
|
{Key: "model", Value: displayValue(m.modelName, "none")},
|
|
{Key: "effort", Value: m.effortDisplay()},
|
|
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
|
|
{Key: "usage", Value: m.usageSummaryText()},
|
|
{Key: "cache", Value: m.cacheEfficiencyText()},
|
|
{Key: "latency", Value: m.avgTurnLatencyText()},
|
|
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
|
|
},
|
|
},
|
|
{
|
|
Title: "Session",
|
|
Fields: []commandField{
|
|
{Key: "active", Value: displayValue(m.activeSession.SessionID, "none")},
|
|
{Key: "root", Value: displayValue(m.sessionRootDir(), "unknown")},
|
|
{Key: "compaction", Value: contextCompactionStatus(m.compactionStatus())},
|
|
},
|
|
},
|
|
{
|
|
Title: "Tools",
|
|
Fields: []commandField{
|
|
{Key: "registered", Value: fmt.Sprint(toolCount)},
|
|
},
|
|
},
|
|
},
|
|
Actions: []string{"/permissions manage access", "/tools inspect catalog"},
|
|
})
|
|
}
|
|
|
|
func (m model) registeredTools() []tools.Tool {
|
|
if m.registry == nil {
|
|
return nil
|
|
}
|
|
return m.registry.All()
|
|
}
|
|
|
|
func (m model) sessionRootDir() string {
|
|
if m.sessionStore == nil {
|
|
return ""
|
|
}
|
|
return m.sessionStore.RootDir
|
|
}
|
|
|
|
func pluralizeCount(count int, singular string, plural string) string {
|
|
label := plural
|
|
if count == 1 {
|
|
label = singular
|
|
}
|
|
return fmt.Sprintf("%d %s", count, label)
|
|
}
|
|
|
|
func contextCompactionStatus(status string) string {
|
|
if status == "not compacted" {
|
|
return "idle"
|
|
}
|
|
return status
|
|
}
|
|
|
|
// onOff renders a boolean preference as "on"/"off" for config display.
|
|
func onOff(v bool) string {
|
|
if v {
|
|
return "on"
|
|
}
|
|
return "off"
|
|
}
|
|
|
|
func (m model) configText() string {
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Config",
|
|
Status: commandStatusOK,
|
|
Sections: []commandSection{
|
|
{
|
|
Title: "Runtime",
|
|
Lines: []string{
|
|
"runtime: go",
|
|
fmt.Sprintf("max turns: %d", m.agentOptions.MaxTurns),
|
|
"permission mode: " + string(m.permissionMode),
|
|
"recaps: " + onOff(m.recapsEnabled),
|
|
},
|
|
},
|
|
{
|
|
Title: "Provider",
|
|
Lines: []string{
|
|
"provider: " + displayValue(m.providerName, "none"),
|
|
"model: " + displayValue(m.modelName, "none"),
|
|
"api key: " + apiKeyState(strings.TrimSpace(m.providerProfile.APIKey) != "" || m.providerProfile.APIKeyStored),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m model) debugText() string {
|
|
state := "idle"
|
|
if m.pending {
|
|
state = "running"
|
|
}
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Debug",
|
|
Status: commandStatusInfo,
|
|
Sections: []commandSection{{
|
|
Title: "Runtime",
|
|
Lines: []string{
|
|
"run state: " + state,
|
|
"active run: " + fmt.Sprint(m.activeRunID),
|
|
"pending permission: " + boolText(m.pendingPermission != nil),
|
|
},
|
|
}},
|
|
})
|
|
}
|
|
|
|
// skillsText is the /skills fallback when NO skills are installed — an install
|
|
// hint. With skills present /skills opens the searchable skill picker instead
|
|
// (see newSkillPicker), matching how /model works.
|
|
func (m model) skillsText() string {
|
|
return renderCommandOutput(commandOutput{
|
|
Title: "Skills",
|
|
Status: commandStatusInfo,
|
|
Sections: []commandSection{{
|
|
Lines: []string{"No skills installed."},
|
|
}},
|
|
Hints: []string{
|
|
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
|
|
},
|
|
})
|
|
}
|