mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* fix(provider): stop dropping custom no-auth providers on restart Custom OpenAI/Anthropic-compatible catalog entries hardcode RequiresAuth as a template default for the setup wizard, not a fact about the user's actual endpoint. Leaving the credential step blank (e.g. a local llama.cpp server) still stamped the saved profile with a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same static RequiresAuth flag then made the profile look like it was missing a required credential on the next run, silently filtering it out of /model. Add a Custom flag to the catalog descriptor so both the wizard (write side) and setupMissingCredentialEnv (read side) judge these profiles by their own explicit APIKeyEnv instead of the catalog's static default. Fixes #555 Co-Authored-By: Claude Sonnet 5 <[email protected]> Co-authored-by: Pierre Bruno <[email protected]> * fix(provider): self-heal legacy custom profiles stamped by the old wizard bug CodeRabbit flagged that profiles saved before this fix could already carry the phantom APIKeyEnv the old wizard stamped on every custom provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would keep treating any non-empty APIKeyEnv as an explicit requirement — leaving already-affected users' providers filtered out of /model even after upgrading. Treat an APIKeyEnv that exactly matches the catalog's own guessed default as indistinguishable from unset, so already-broken saved profiles self-heal without a config.json migration. A deliberately different env var name (e.g. via --api-key-env) still means auth is required. Co-Authored-By: Claude Sonnet 5 <[email protected]> Co-authored-by: Pierre Bruno <[email protected]> * fix(provider): centralize custom-endpoint credential logic, fix two more surfaces Copilot's review of the earlier fix found the same RequiresAuth-as- authoritative bug in two more places that decide whether a saved provider needs a credential: - validateProviderRuntimeReady (zero providers check) would hard-error a no-auth custom endpoint with "requires API key". - providerWizardCredentialLabel and providerCredentialRequired (/providers status, wizard summary) would still claim/warn that one was needed. Added config.ProviderProfile.MissingCredentialEnv as the single source of truth for this decision (custom-endpoint self-heal included), and had setupMissingCredentialEnv and validateProviderRuntimeReady delegate to it. providerWizardCredentialLabel and providerCredentialRequired take the same Custom-aware fix directly, since their non-catalog fallback path uses runtime-resolved provider-kind metadata that shouldn't be collapsed into the canonical helper. Co-Authored-By: Claude Sonnet 5 <[email protected]> Co-authored-by: Pierre Bruno <[email protected]> * fix(ci): request pinned Go toolchain directly Co-authored-by: Pierre Bruno <[email protected]> --------- Co-authored-by: Amp <[email protected]>
736 lines
21 KiB
Go
736 lines
21 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)
|
|
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
|
|
// authenticated — without this, /provider status showed a WORKING provider
|
|
// as "api key: not set" with a warning, unlike `zero providers list` which
|
|
// already fills OAuthLogin.
|
|
if !snapshot.APIKeySet {
|
|
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
|
|
}
|
|
credentialState := apiKeyState(snapshot.APIKeySet)
|
|
if !snapshot.APIKeySet && snapshot.OAuthLogin {
|
|
credentialState = "oauth login"
|
|
}
|
|
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: "+credentialState,
|
|
)
|
|
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) && !snapshot.OAuthLogin {
|
|
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 {
|
|
// A custom endpoint's RequiresAuth is just the wizard's template
|
|
// default, not a fact about this profile's actual endpoint (issue
|
|
// #555 follow-up) — only an explicit, non-default APIKeyEnv means
|
|
// this saved profile itself expects a credential.
|
|
if descriptor.Custom {
|
|
envVar := strings.TrimSpace(profile.APIKeyEnv)
|
|
return envVar != "" && envVar != firstProviderDisplayValue(descriptor.AuthEnvVars...)
|
|
}
|
|
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`)",
|
|
},
|
|
})
|
|
}
|