Files
zero/internal/tui/command_center.go
Anandan d1c5af7392 Polish model status and reasoning streams (#190)
* Polish model status and reasoning streams

* Polish custom provider setup flow

* Clean up pending composer hints

* Address custom provider setup review

* Polish composer overflow handling

* Fix composer paste and wheel edge cases

* Use line counts for paste previews

* Revert "Use line counts for paste previews"

This reverts commit 4feb814d09.

* Fix composer paste preview deletion

* Disable textinput ctrl-v paste binding

* Preserve paste previews through file suggestions

* Clarify composer paste preview behavior

* Keep OpenAI reasoning out of answer text
2026-06-13 22:40:32 +05:30

473 lines
15 KiB
Go

package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
zsearch "github.com/Gitlawb/zero/internal/search"
)
func (m model) doctorText() string {
report := doctor.Run(doctor.Options{
Now: m.now,
Runtime: "go",
Provider: m.providerProfile,
})
return doctor.Format(report)
}
func (m model) searchText(query string) string {
query = strings.TrimSpace(query)
if query == "" {
return "Search\nusage: /search <query>"
}
result, err := zsearch.Sessions(query, zsearch.Options{
Store: m.sessionStore,
Limit: 5,
ContextChars: 120,
Now: m.now,
})
if err != nil {
return "Search\nerror: " + err.Error()
}
return zsearch.FormatResult(zsearch.RedactResult(result))
}
func (m model) resumeText(args string) string {
args = strings.TrimSpace(args)
if args != "" {
return renderCommandOutput(commandOutput{
Title: "Sessions",
Status: commandStatusInfo,
Sections: []commandSection{{
Title: "Resume",
Lines: []string{"requested session: " + args},
}},
Hints: []string{"use /resume " + args + " to hydrate this TUI session"},
})
}
// Only standalone conversations — not child/spec sub-runs, which an agent
// spawns by the dozen and would otherwise flood the picker (the "… N more").
sessions, err := m.sessionStore.ListResumable()
if err != nil {
return renderCommandOutput(commandOutput{
Title: "Sessions",
Status: commandStatusBlocked,
Sections: []commandSection{{
Title: "Store",
Lines: []string{"error: " + err.Error()},
}},
})
}
if len(sessions) == 0 {
return renderCommandOutput(commandOutput{
Title: "Sessions",
Status: commandStatusInfo,
Sections: []commandSection{{
Title: "Recent",
Lines: []string{"none"},
}},
})
}
limit := len(sessions)
if limit > 8 {
limit = 8
}
// The list renders as stacked cards (renderSessionsCards); each record is
// one session's fields joined by the unit separator so the renderer can
// restyle them at the current width. Flow and data are unchanged.
records := make([]string, 0, limit+1)
for index := 0; index < limit; index++ {
session := sessions[index]
meta := strings.Join([]string{
sanitizeCardField(displayValue(session.ModelID, "no model")),
sanitizeCardField(displayValue(session.Provider, "no provider")),
fmt.Sprintf("%d events", session.EventCount),
}, " · ")
records = append(records, strings.Join([]string{
sanitizeCardField(session.SessionID),
relativeAge(session.UpdatedAt, m.now()),
sanitizeCardField(displayValue(session.Title, "untitled")),
meta,
}, sessionsCardFieldSep))
}
if len(sessions) > limit {
records = append(records, fmt.Sprintf("… %d more · /resume <id>", len(sessions)-limit))
} else {
records = append(records, "use /resume latest or /resume <id> to load a session")
}
return sessionsCardsPrefix + strings.Join(records, "\n")
}
const (
// sessionsCardsPrefix marks a resumeText payload that renders as stacked
// session cards instead of a plain system note.
sessionsCardsPrefix = "\x00sessions\x00"
// sessionsCardFieldSep separates the id/age/title/meta fields of one card.
sessionsCardFieldSep = "\x1f"
)
type modelSwitchCompactionRequest struct {
CurrentModel string
TargetModel string
CurrentProvider string
TargetProvider string
CurrentContextWindow int
TargetContextWindow int
EstimatedTokens int
SessionEventCount int
CompactRequests int
}
type modelSwitchCompactionDecision struct {
RequestCompaction bool
Reason string
}
type modelSwitchCompactionPolicy interface {
BeforeModelSwitch(modelSwitchCompactionRequest) modelSwitchCompactionDecision
}
type defaultModelSwitchCompactionPolicy struct{}
func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchCompactionRequest) modelSwitchCompactionDecision {
if request.CompactRequests > 0 || request.SessionEventCount <= tuiCompactionPreserveLast {
return modelSwitchCompactionDecision{}
}
if request.TargetContextWindow <= 0 || request.EstimatedTokens <= 0 {
return modelSwitchCompactionDecision{}
}
threshold := int(float64(request.TargetContextWindow) * 0.8)
if request.EstimatedTokens < threshold {
return modelSwitchCompactionDecision{}
}
return modelSwitchCompactionDecision{
RequestCompaction: true,
Reason: fmt.Sprintf("estimated context %s tokens is near target context %s tokens", formatContextWindow(request.EstimatedTokens), formatContextWindow(request.TargetContextWindow)),
}
}
type noopModelSwitchCompactionPolicy struct{}
func (noopModelSwitchCompactionPolicy) BeforeModelSwitch(modelSwitchCompactionRequest) modelSwitchCompactionDecision {
return modelSwitchCompactionDecision{}
}
var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{}
// sanitizeCardField strips the card protocol's separator bytes from
// user-controlled values (titles can legally contain anything --session-title
// was given), so a hostile or accidental \x1f / newline cannot shift fields
// or leak control characters into the transcript.
func sanitizeCardField(value string) string {
value = strings.ReplaceAll(value, sessionsCardFieldSep, " ")
value = strings.ReplaceAll(value, "\n", " ")
return strings.ReplaceAll(value, "\x00", "")
}
// relativeAge renders an RFC3339 timestamp as a short age ("2h ago"); ""
// when the timestamp does not parse, so the card simply omits it.
func relativeAge(timestamp string, now time.Time) string {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(timestamp))
if err != nil {
return ""
}
age := now.Sub(parsed)
switch {
case age < time.Minute:
return "just now"
case age < time.Hour:
return fmt.Sprintf("%dm ago", int(age.Minutes()))
case age < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(age.Hours()))
default:
return fmt.Sprintf("%dd ago", int(age.Hours()/24))
}
}
func (m model) handleModelCommand(args string) (model, string) {
args = strings.TrimSpace(args)
switch strings.ToLower(args) {
case "":
return m, m.modelText(args)
case "list", "ls":
return m, m.modelListText()
}
if m.pending {
return m, "Model\nCannot switch models while a run is active."
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return m, "Model\nFailed to load model catalog: " + err.Error()
}
target, ok := m.resolveModelSwitchTarget(registry, args)
if !ok {
return m, "Model\nunknown Zero model " + strconv.Quote(args)
}
if !config.HasProviderProfile(m.providerProfile) {
return m, "Model\nNo provider profile is available for TUI model switching."
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session."
}
nextProfile := m.providerProfile
if provider, ok := m.activeProviderDescriptor(); ok {
nextProfile = m.normalizeProfileForProvider(provider)
}
nextProfile.Model = target.modelID
metadata, err := providers.ResolveRuntimeMetadata(nextProfile, providers.Options{})
if err != nil {
return m, "Model\n" + err.Error()
}
if guarded, text, requested := m.requestCompactionBeforeModelSwitch(modelSwitchCompactionRequest{
TargetModel: target.modelID,
TargetProvider: string(metadata.ProviderKind),
TargetContextWindow: modelContextWindow(target.modelID),
}, "Model"); requested {
return guarded, text
}
nextProvider, err := m.newProvider(nextProfile)
if err != nil {
return m, "Model\n" + err.Error()
}
m.providerProfile = nextProfile
m.provider = nextProvider
m.providerName = displayValue(nextProfile.Name, string(metadata.ProviderKind))
m.modelName = target.modelID
resetEffort := false
if m.reasoningEffort != "" && !reasoningEffortAllowed(target.reasoningEfforts, m.reasoningEffort) {
// Drop an unsupported carry-over preference and fall back to the
// model's effective default for the new model.
m.reasoningEffort = ""
resetEffort = true
}
effortLine := "effort: " + m.effortDisplay()
if resetEffort {
// Preference was dropped: show "auto" (model default applies), not a
// concrete value that would read as an explicit setting.
effortLine += " (unsupported preference reset)"
} else if target.entry != nil {
if effective := modelregistry.EffectiveReasoningEffort(*target.entry, m.reasoningEffort); effective != modelregistry.ReasoningEffortNone {
effortLine = "effort: " + string(effective)
}
}
lines := []string{"Model"}
if target.notice != "" {
lines = append(lines, target.notice)
}
lines = append(lines,
"Switched model for this TUI session.",
"model: "+target.modelID,
"provider: "+displayValue(nextProfile.Name, string(metadata.ProviderKind)),
"api model: "+metadata.APIModel,
effortLine,
)
return m, strings.Join(lines, "\n")
}
type modelSwitchTarget struct {
modelID string
entry *modelregistry.ModelEntry
notice string
reasoningEfforts []modelregistry.ReasoningEffort
}
func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args string) (modelSwitchTarget, bool) {
entry, notice, ok := registry.ResolveWithFallback(args)
if ok {
return modelSwitchTarget{
modelID: entry.ID,
entry: &entry,
notice: notice,
reasoningEfforts: entry.ReasoningEfforts,
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
if m.modelPickerLiveProviderID == provider.ID {
for _, model := range m.modelPickerLiveModels {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
return modelSwitchTarget{}, false
}
// handleModeCommand applies a preset that bundles model, reasoning effort, and
// turn budget. "/mode" with no argument lists the presets; "/mode <name>"
// switches the active model (rebuilding the provider, like /model), the reasoning
// effort (like /effort), and the agent-loop turn budget for this TUI session. It
// mirrors the state mutations in handleModelCommand/handleEffortCommand so a mode
// switch is equivalent to running those commands in sequence.
func (m model) handleModeCommand(args string) (model, string) {
args = strings.TrimSpace(args)
switch strings.ToLower(args) {
case "":
return m, m.modeListText()
case "list", "ls":
return m, m.modeListText()
}
mode, ok := modelregistry.LookupMode(args)
if !ok {
return m, "Mode\nunknown mode " + strconv.Quote(args) + "\navailable: " + strings.Join(modelregistry.ModeNames(), ", ")
}
if m.pending {
return m, "Mode\nCannot switch modes while a run is active."
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return m, "Mode\nFailed to load model catalog: " + err.Error()
}
entry, notice, ok := registry.ResolveWithFallback(mode.Model)
if !ok {
return m, "Mode\nmode " + strconv.Quote(mode.Name) + " references unknown model " + strconv.Quote(mode.Model)
}
if !config.HasProviderProfile(m.providerProfile) {
return m, "Mode\nNo provider profile is available for TUI mode switching."
}
if m.newProvider == nil {
return m, "Mode\nProvider rebuild is not available for this TUI session."
}
nextProfile := m.providerProfile
nextProfile.Model = entry.ID
metadata, err := providers.ResolveRuntimeMetadata(nextProfile, providers.Options{})
if err != nil {
return m, "Mode\n" + err.Error()
}
if guarded, text, requested := m.requestCompactionBeforeModelSwitch(modelSwitchCompactionRequest{
TargetModel: entry.ID,
TargetProvider: string(metadata.ProviderKind),
TargetContextWindow: modelContextWindow(entry.ID),
}, "Mode"); requested {
return guarded, text
}
nextProvider, err := m.newProvider(nextProfile)
if err != nil {
return m, "Mode\n" + err.Error()
}
m.providerProfile = nextProfile
m.provider = nextProvider
m.providerName = displayValue(nextProfile.Name, string(metadata.ProviderKind))
m.modelName = entry.ID
// Apply the mode's reasoning effort when the resolved model supports it;
// otherwise fall back to auto (the model's effective default) so we never
// store an unsupported preference.
effortLine := "effort: auto"
if mode.Effort != "" && reasoningEffortAllowed(entry.ReasoningEfforts, mode.Effort) {
m.reasoningEffort = mode.Effort
effortLine = "effort: " + string(mode.Effort)
} else {
m.reasoningEffort = ""
if mode.Effort != "" {
effortLine = "effort: auto (mode effort unsupported by model)"
}
}
turnsLine := fmt.Sprintf("max turns: %d (unchanged)", m.agentOptions.MaxTurns)
if mode.MaxTurns > 0 {
m.agentOptions.MaxTurns = mode.MaxTurns
turnsLine = fmt.Sprintf("max turns: %d", mode.MaxTurns)
}
lines := []string{"Mode"}
if notice != "" {
lines = append(lines, notice)
}
lines = append(lines,
"Switched to mode "+mode.Name+" for this TUI session.",
mode.Description,
"model: "+entry.ID,
"provider: "+displayValue(nextProfile.Name, string(metadata.ProviderKind)),
effortLine,
turnsLine,
)
return m, strings.Join(lines, "\n")
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
}
request.CurrentModel = m.modelName
request.CurrentProvider = m.providerName
request.CurrentContextWindow = modelContextWindow(m.modelName)
request.EstimatedTokens = estimateTranscriptTokens(m.transcript)
request.SessionEventCount = len(m.sessionEvents)
request.CompactRequests = m.compactRequests
decision := modelSwitchCompactionGuard.BeforeModelSwitch(request)
if !decision.RequestCompaction {
return m, "", false
}
m.compactRequests++
lines := []string{
title,
"Context compaction requested before switching models.",
"The active model/provider is unchanged until compaction can run.",
"from model: " + displayValue(request.CurrentModel, "none"),
"to model: " + displayValue(request.TargetModel, "none"),
}
if request.TargetProvider != "" {
lines = append(lines, "target provider: "+request.TargetProvider)
}
if reason := strings.TrimSpace(decision.Reason); reason != "" {
lines = append(lines, "reason: "+reason)
}
lines = append(lines, "compaction: "+m.compactionStatus())
return m, strings.Join(lines, "\n"), true
}
func (m model) modeListText() string {
lines := make([]string, 0, len(modelregistry.Modes()))
for _, mode := range modelregistry.Modes() {
detail := fmt.Sprintf("model=%s", mode.Model)
if mode.Effort != "" {
detail += " effort=" + string(mode.Effort)
}
if mode.MaxTurns > 0 {
detail += fmt.Sprintf(" turns=%d", mode.MaxTurns)
}
lines = append(lines, commandBullet(fmt.Sprintf("%s - %s (%s)", mode.Name, mode.Description, detail)))
}
return renderCommandOutput(commandOutput{
Title: "Mode",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Available",
Lines: lines,
}},
Hints: []string{"use /mode <name> to switch model, effort, and turns"},
})
}
func apiKeyState(set bool) string {
if set {
return "set"
}
return "not set"
}