mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* feat(tui): offer mid-run model escalation behind --allow-escalation Mid-run escalation existed but only exec offered it. The TUI already handled every consequence of a switch, re-deriving the compaction threshold "after a mid-run escalate_model switch" and resolving the summarizer against the active profile, while nothing on that surface could cause one: escalate_model was registered by exec alone. This closes that, opt-in, the same conservative way exec answers the question. Opt-in rather than default because escalation moves a run onto a different model, which changes what it costs and which provider sees the conversation. That is the operator's call, and the exec flag already answers it. The switchers are built per turn from m.providerProfile rather than once at launch. A TUI session can change models with /model, so a closure capturing the startup profile would escalate from whatever the session began with instead of what is in force now, and would carry that stale profile's base URL and credential with it. exec has no such problem because its profile cannot change mid-run, which is why the wiring lives in different places on the two surfaces. The nil-contract logic is now shared rather than copied. providers. EscalationSwitchers is the single implementation both surfaces call: the loop swaps only on a non-nil provider, so a (nil, nil) return has to leave everything untouched including the caller's usage attribution, and the session switcher is installed only when the run STARTED optimized so escalation cannot change the transport underneath a session. A second copy of that would only ever have been exercised on one surface. exec's behaviour is unchanged: its six existing escalation tests pass against the shared builder. The tool and the switchers ride on one flag and a test asserts they cannot be separated. Registering escalate_model without wiring a switcher ships a tool the loop silently ignores, so the model reports an escalation that never happened; that is the specific failure worth a guard rather than a comment. Note this does not answer all of #554. Assigning models per phase, plan with one and execute with another, is still not built. This makes the mechanism reachable from the interactive surface, which is the part that was one flag away. Refs #554 * fix(tui): attribute usage after an escalation to the model in force exec reassigns currentModel when the escalation switcher fires; the TUI captured usageModelID once per run and handed the switcher no callback, so every usage event after a mid-run escalation was billed to the model the run started on. The switcher now updates usageModelID, and the final response carries the model per usage event so the batch fallback does not swing the other way and bill the events before the switch to the escalated model. Covered by a run through a scripted escalation that checks both the live and the batch attribution. * fix(cli): accept the root flags in any order Each leading-flag splitter stops at the first token it does not own, so running them once in a fixed sequence made the order load-bearing: `zero --allow-escalation --theme auto` stranded the theme as an unknown command and exited with an argument error instead of launching, and the same happened after --skip-permissions-unsafe. The three splitters now run until they make no progress, at the root and after the unsafe flag, so every ordering reaches the TUI with every flag applied. A --theme written before --skip-permissions-unsafe was dropped on that path; it is kept now, with a later one winning as the last occurrence does. * fix(cli): forward a root --allow-escalation into exec, reject it elsewhere The root flag was stripped before dispatch, so `zero --allow-escalation exec ...` and `zero --allow-escalation -p ...` ran without the opt-in the help text promises, and `zero --allow-escalation version` accepted a flag it could only discard. It now follows --add-dir: re-synthesised into the exec argument list for the exec and -p shapes, so exec parses it as its own flag, and rejected loudly for every other command. The ordering test now generates every permutation of the three root flags, with --skip-permissions-unsafe absent or at any position. * fix(tui): record the model on an escalated run persisted usage events The live usage record carried the model in force; the persisted session payload did not. The usage report rebuilds cost from that payload and falls back to the session-wide model when an event does not name one, so an escalated interactive run was priced end to end at the model it started on. Written only under escalation, matching what exec records under the same flag, so an ordinary run persists the same compact payload as before. * test(cli): isolate the new launch helpers from real user state A temporary working directory isolates project files and nothing else. runWithDeps fills the dependencies a test leaves out with production ones, and the interactive launch path reads user config, opens stores, refreshes the models.dev cache and migrates any inline plaintext API key into the credential store before it reaches an injected runTUI callback. Running these tests on a developer machine therefore rewrote that developer's config.json and wrote a credential file beside it, and the results depended on whatever providers, MCP servers and plugins the machine had. Both new helpers now point every per-user base directory at a throwaway root, pin the models.dev cache path, disable its background fetch, and force the file credential backend so nothing reaches the host keyring. A new test seeds a config with an inline key outside those fixture roots and fails if either helper rewrites it or leaves a file beside it; without the isolation it reports the rewritten config and the credentials.enc files. * test(cli): check each launch helper isolation on its own t.Setenv restores at the end of the test, not when the helper returns, so one test exercising both helpers let the first isolation cover for the second. Each helper now seeds its own emulated user config in a subtest, and removing the isolation from either one fails that subtest. * test(cli): pin the isolation itself, not only what this platform writes The exec helper writes its models.dev cache from a background goroutine, so a footprint check could not prove its isolation deterministically. Each subtest now also asserts the helper moved the per-user paths off the seeded root and disabled the fetch and the keyring backend, which fails for either helper when the isolation call is removed.
190 lines
7.8 KiB
Go
190 lines
7.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/peermsg"
|
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
"github.com/Gitlawb/zero/internal/sessions"
|
|
"github.com/Gitlawb/zero/internal/skills"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
"github.com/Gitlawb/zero/internal/usage"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
// Options configures the reusable Zero terminal UI shell.
|
|
type Options struct {
|
|
Cwd string
|
|
Version string // CLI build version, shown on the home screen; empty hides it
|
|
UserConfigPath string
|
|
DoctorUserConfigPath string
|
|
ProjectConfigPath string
|
|
ProviderName string
|
|
ModelName string
|
|
ProviderProfile config.ProviderProfile
|
|
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
|
|
FavoriteModels []string
|
|
RecentModels []config.RecentModelEntry
|
|
RecapsEnabled bool
|
|
// CompactionModel is the resolved preferences.compactionModel value; see
|
|
// providers.CompactionModelID for how it combines with the env override
|
|
// and the curated cheap defaults.
|
|
CompactionModel string
|
|
Provider zeroruntime.Provider
|
|
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
|
NewTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
|
|
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
|
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
|
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
|
RuntimeMessageSink func(tea.Msg)
|
|
PrepareRunCompletionWarning func()
|
|
RunCompletionWarning func() string
|
|
Registry *tools.Registry
|
|
// AwaitToolReadiness gives prompt-critical integration startup a bounded
|
|
// chance to publish its tools before this turn snapshots the registry. The
|
|
// wait runs inside the asynchronous agent command, so the TUI stays usable.
|
|
AwaitToolReadiness func(context.Context)
|
|
SessionStore *sessions.Store
|
|
SandboxStore *sandbox.GrantStore
|
|
MCPConfig config.MCPConfig
|
|
MCPPermissionStore *mcp.PermissionStore
|
|
MCPTokenStore *mcp.TokenStore
|
|
MCPCommand func(context.Context, []string) MCPCommandResult
|
|
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
|
UsageTracker *usage.Tracker
|
|
SessionCompactor SessionCompactor
|
|
PrService *PrService
|
|
PeerService *peermsg.Service
|
|
|
|
AgentOptions agent.Options
|
|
// LoadSkills returns the installed skills (default skills dir merged with any
|
|
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
|
|
// invocation. Called lazily per use so newly installed skills are picked up
|
|
// without a restart. Nil means the session has no skills wiring (skills stay
|
|
// model-pulled via the skill tool only).
|
|
LoadSkills func() []skills.Skill
|
|
// AllowEscalation opts this session into mid-run model escalation, set from
|
|
// --allow-escalation. It wires the model switchers onto every turn's options;
|
|
// the escalate_model tool itself is registered by the caller on the same flag.
|
|
// Both halves are required: the tool without the switchers is inert, and the
|
|
// switchers without the tool are unreachable.
|
|
AllowEscalation bool
|
|
PermissionMode agent.PermissionMode
|
|
ReasoningEffort modelregistry.ReasoningEffort
|
|
ResponseStyle string
|
|
// Theme is the operator's palette preference: "auto" (default), a built-in
|
|
// ("dark"/"light"), or a registered color theme. Set from the --theme flag;
|
|
// falls back to ZERO_THEME, then the persisted SavedTheme, then auto.
|
|
Theme string
|
|
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
|
|
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
|
|
SavedTheme string
|
|
// SavedPet is the persisted terminal companion id. Empty means no pet has
|
|
// been selected yet; "disabled" records an explicit opt-out.
|
|
SavedPet string
|
|
UserAgent string
|
|
|
|
// Notify configures completion / awaiting-input notifications.
|
|
Notify config.NotifyConfig
|
|
|
|
// KeyBindings configures remappable TUI keybindings. An empty/zero
|
|
// KeyBindingsConfig means "use built-in defaults" for each action.
|
|
KeyBindings config.KeyBindingsConfig
|
|
|
|
// STT configures speech-to-text dictation (§ docs/dictation.md).
|
|
STT config.STTConfig
|
|
// BuildDictationTranscriber constructs the transcriber for the current STT
|
|
// config. It lives in the CLI layer because it resolves provider API keys
|
|
// (credstore + env) and base URLs; the TUI only calls it when a recording
|
|
// starts. preferStreaming asks for the streaming backend; the returned
|
|
// `streaming` bool reports whether streaming is actually available (false
|
|
// falls the caller back to the batch pipeline). Nil disables dictation (the
|
|
// keybinding shows a "not configured" hint). cfg is passed each call (not
|
|
// captured) so a mid-session config change — e.g. the auto-download writing
|
|
// localModelPath — takes effect on the next recording.
|
|
BuildDictationTranscriber func(cfg config.STTConfig, preferStreaming bool) (t Transcriber, streaming bool, err error)
|
|
|
|
// ShutdownDictationServer tears down the warm sherpa-onnx streaming server (if
|
|
// one was started), called alongside the LSP manager's shutdown on exit. Nil
|
|
// when dictation is not wired.
|
|
ShutdownDictationServer func(context.Context) error
|
|
|
|
// STTDownloadRoot is where the auto-download stores the sherpa-onnx engine
|
|
// and model (e.g. ~/.config/zero/stt). Empty disables auto-download (the F9
|
|
// setup message then only points at manual setup / cloud providers).
|
|
STTDownloadRoot string
|
|
|
|
// STTKeyStatus reports whether an API key is already resolvable for a cloud
|
|
// STT provider ("groq"/"openai"/"deepgram"). Nil disables the inline key
|
|
// prompt (dictation then just shows the "run zero auth" setup error).
|
|
STTKeyStatus func(provider string) bool
|
|
// SaveSTTKey stores an API key for a cloud STT provider in the credential
|
|
// store, so the inline prompt can capture and persist it.
|
|
SaveSTTKey func(provider, key string) error
|
|
|
|
// AltScreen tells the model it is running inside Bubble Tea's alternate
|
|
// screen. Run sets this for the interactive app; tests can leave it false
|
|
// to exercise the native scrollback renderer.
|
|
AltScreen bool
|
|
|
|
// Setup configures the first-run/setup takeover. It is shown before the
|
|
// normal chat surface when Visible is true.
|
|
Setup SetupOptions
|
|
}
|
|
|
|
type MCPCommandResult struct {
|
|
Config config.MCPConfig
|
|
Output string
|
|
Error string
|
|
ExitCode int
|
|
}
|
|
|
|
type SandboxSetupCommandResult struct {
|
|
Output string
|
|
Error string
|
|
ExitCode int
|
|
}
|
|
|
|
// SetupOptions configures the guided first-run provider setup takeover.
|
|
type SetupOptions struct {
|
|
Visible bool
|
|
Required bool
|
|
ConfigPath string
|
|
Providers []SetupProviderOption
|
|
Save func(SetupSelection) (SetupResult, error)
|
|
}
|
|
|
|
// SetupProviderOption is one provider choice offered by the setup takeover.
|
|
type SetupProviderOption struct {
|
|
ID string
|
|
Name string
|
|
DefaultModel string
|
|
EnvVar string
|
|
RequiresAuth bool
|
|
Local bool
|
|
Recommended bool
|
|
}
|
|
|
|
// SetupSelection is the user's setup choice.
|
|
type SetupSelection struct {
|
|
CatalogID string
|
|
Name string
|
|
BaseURL string
|
|
Model string
|
|
APIKey string
|
|
}
|
|
|
|
// SetupResult describes a completed setup write.
|
|
type SetupResult struct {
|
|
ConfigPath string
|
|
Provider config.ProviderProfile
|
|
}
|