Skip to content

Commit fcfdb87

Browse files
authored
Polish main TUI surface (#168)
* Polish main TUI surface Simplify the empty chat surface, replace starter prompt chips with a cleaner brand state, move model and permission state into the composer frame, quiet the footer status, and improve composer word-editing keybindings. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Polish autocomplete command palette * Polish TUI file mention picker * Address TUI review feedback * Keep autocomplete anchored above composer * Center autocomplete over chat viewport * Layer autocomplete overlays over transcript * Prefill argument commands from palette * Show argument hints for command completions * Polish TUI provider setup * Polish TUI picker and composer metadata * Add two-step mouse selection in TUI * Polish TUI mouse selection flows * Make provider wizard navigation test hermetic * Fix TUI review edge cases
1 parent 06ea4f0 commit fcfdb87

36 files changed

Lines changed: 4194 additions & 486 deletions

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ require (
88
github.com/charmbracelet/bubbles v1.0.0
99
github.com/charmbracelet/bubbletea v1.3.10
1010
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
11+
github.com/charmbracelet/x/ansi v0.11.6
1112
golang.org/x/sys v0.38.0
1213
)
1314

1415
require (
1516
github.com/atotto/clipboard v0.1.4 // indirect
1617
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1718
github.com/charmbracelet/colorprofile v0.4.1 // indirect
18-
github.com/charmbracelet/x/ansi v0.11.6 // indirect
1919
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
2020
github.com/charmbracelet/x/term v0.2.2 // indirect
2121
github.com/clipperhouse/displaywidth v0.9.0 // indirect

‎internal/cli/app.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
436436
ProviderName: resolved.Provider.Name,
437437
ModelName: resolved.Provider.Model,
438438
ProviderProfile: resolved.Provider,
439+
FavoriteModels: resolved.Preferences.FavoriteModels,
439440
Provider: provider,
440441
NewProvider: deps.newProvider,
441442
Registry: registry,

‎internal/cli/app_test.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
145145
APIKey: "sk-test",
146146
Model: "gpt-test",
147147
},
148-
MaxTurns: 5,
148+
Preferences: config.PreferencesConfig{FavoriteModels: []string{"qwen3-coder:480b"}},
149+
MaxTurns: 5,
149150
}, nil
150151
},
151152
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
@@ -179,6 +180,9 @@ func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
179180
if launchedOptions.ModelName != "gpt-test" {
180181
t.Fatalf("ModelName = %q, want gpt-test", launchedOptions.ModelName)
181182
}
183+
if len(launchedOptions.FavoriteModels) != 1 || launchedOptions.FavoriteModels[0] != "qwen3-coder:480b" {
184+
t.Fatalf("FavoriteModels = %#v, want qwen3-coder:480b", launchedOptions.FavoriteModels)
185+
}
182186
if launchedOptions.Setup.Visible {
183187
t.Fatalf("Setup.Visible = true, want false for credentialed provider")
184188
}

‎internal/config/resolver.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
103103
Sandbox: cfg.Sandbox,
104104
Notify: cfg.Notify,
105105
Tools: cfg.Tools,
106+
Preferences: cfg.Preferences,
106107
}, nil
107108
}
108109

@@ -164,6 +165,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
164165
dst.Tools.DeferThreshold = src.Tools.DeferThreshold
165166
dst.Tools.deferThresholdSet = true
166167
}
168+
if src.Preferences.FavoriteModels != nil {
169+
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
170+
}
167171
}
168172

169173
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {

‎internal/config/resolver_test.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,40 @@ func TestResolveSelectsActiveProviderProfile(t *testing.T) {
9292
}
9393
}
9494

95+
func TestResolveLoadsFavoriteModelsFromUserConfigOnly(t *testing.T) {
96+
userPath := writeConfig(t, `{
97+
"activeProvider": "user",
98+
"providers": [{
99+
"name": "user",
100+
"provider": "openai",
101+
"apiKey": "sk-user",
102+
"model": "gpt-user"
103+
}],
104+
"preferences": {
105+
"favoriteModels": [" rnj-1:8b ", "qwen3-coder:480b", "rnj-1:8b"]
106+
}
107+
}`)
108+
projectPath := writeConfig(t, `{
109+
"preferences": {
110+
"favoriteModels": ["project-model"]
111+
}
112+
}`)
113+
114+
resolved, err := Resolve(ResolveOptions{
115+
UserConfigPath: userPath,
116+
ProjectConfigPath: projectPath,
117+
Env: map[string]string{},
118+
})
119+
if err != nil {
120+
t.Fatalf("Resolve() error = %v", err)
121+
}
122+
123+
want := []string{"qwen3-coder:480b", "rnj-1:8b"}
124+
if !reflect.DeepEqual(resolved.Preferences.FavoriteModels, want) {
125+
t.Fatalf("FavoriteModels = %#v, want %#v", resolved.Preferences.FavoriteModels, want)
126+
}
127+
}
128+
95129
func TestResolveLoadsProviderCatalogSnakeAndCamelJSONFields(t *testing.T) {
96130
path := writeConfig(t, `{
97131
"activeProvider": "snake",

‎internal/config/types.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ type ToolsConfig struct {
8080
deferThresholdSet bool
8181
}
8282

83+
type PreferencesConfig struct {
84+
FavoriteModels []string `json:"favoriteModels,omitempty"`
85+
}
86+
8387
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
8488
// threshold (including to 0, which disables deferral). Use this for programmatic
8589
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
@@ -113,6 +117,7 @@ type FileConfig struct {
113117
Sandbox SandboxConfig `json:"sandbox,omitempty"`
114118
Notify NotifyConfig `json:"notify,omitempty"`
115119
Tools ToolsConfig `json:"tools,omitempty"`
120+
Preferences PreferencesConfig `json:"preferences,omitempty"`
116121
}
117122

118123
type ResolveOptions struct {
@@ -143,6 +148,7 @@ type ResolvedConfig struct {
143148
Sandbox SandboxConfig
144149
Notify NotifyConfig
145150
Tools ToolsConfig
151+
Preferences PreferencesConfig
146152
}
147153

148154
type MCPConfig struct {
@@ -169,6 +175,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
169175
Sandbox SandboxConfig `json:"sandbox"`
170176
Notify NotifyConfig `json:"notify"`
171177
Tools ToolsConfig `json:"tools"`
178+
Preferences PreferencesConfig `json:"preferences"`
172179
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
173180
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
174181
}
@@ -184,6 +191,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
184191
cfg.Sandbox = raw.Sandbox
185192
cfg.Notify = raw.Notify
186193
cfg.Tools = raw.Tools
194+
cfg.Preferences = raw.Preferences
187195
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
188196
cfg.MCP.Servers = map[string]MCPServerConfig{}
189197
}

‎internal/config/writer.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"sort"
89
"strings"
910
)
1011

@@ -71,6 +72,43 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
7172
return FileConfig{}, fmt.Errorf("provider %q not found", name)
7273
}
7374

75+
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
76+
path = strings.TrimSpace(path)
77+
if path == "" {
78+
return FileConfig{}, fmt.Errorf("config path is required")
79+
}
80+
81+
cfg := FileConfig{}
82+
if data, err := os.ReadFile(path); err == nil {
83+
if err := json.Unmarshal(data, &cfg); err != nil {
84+
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
85+
}
86+
} else if !os.IsNotExist(err) {
87+
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
88+
}
89+
90+
cfg.Preferences.FavoriteModels = normalizeFavoriteModels(models)
91+
if err := writeConfigFile(path, cfg); err != nil {
92+
return FileConfig{}, err
93+
}
94+
return cfg, nil
95+
}
96+
97+
func normalizeFavoriteModels(models []string) []string {
98+
seen := map[string]bool{}
99+
favorites := make([]string, 0, len(models))
100+
for _, model := range models {
101+
model = strings.TrimSpace(model)
102+
if model == "" || seen[model] {
103+
continue
104+
}
105+
seen[model] = true
106+
favorites = append(favorites, model)
107+
}
108+
sort.Strings(favorites)
109+
return favorites
110+
}
111+
74112
func writeConfigFile(path string, cfg FileConfig) error {
75113
dir := filepath.Dir(path)
76114
if dir != "." && dir != "" {

‎internal/config/writer_test.go‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io/fs"
77
"os"
88
"path/filepath"
9+
"reflect"
910
"runtime"
1011
"strings"
1112
"testing"
@@ -178,6 +179,49 @@ func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
178179
}
179180
}
180181

182+
func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
183+
path := filepath.Join(t.TempDir(), "zero.json")
184+
writeConfigFixture(t, path, FileConfig{
185+
ActiveProvider: "openai",
186+
Providers: []ProviderProfile{
187+
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
188+
},
189+
}, 0o600)
190+
191+
cfg, err := SetFavoriteModels(path, []string{" qwen3-coder:480b ", "", "rnj-1:8b", "qwen3-coder:480b"})
192+
if err != nil {
193+
t.Fatalf("SetFavoriteModels() error = %v", err)
194+
}
195+
196+
want := []string{"qwen3-coder:480b", "rnj-1:8b"}
197+
if !reflect.DeepEqual(cfg.Preferences.FavoriteModels, want) {
198+
t.Fatalf("FavoriteModels = %#v, want %#v", cfg.Preferences.FavoriteModels, want)
199+
}
200+
persisted := readConfigFixture(t, path)
201+
if !reflect.DeepEqual(persisted.Preferences.FavoriteModels, want) {
202+
t.Fatalf("persisted FavoriteModels = %#v, want %#v", persisted.Preferences.FavoriteModels, want)
203+
}
204+
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
205+
t.Fatalf("provider config was not preserved: %#v", persisted)
206+
}
207+
}
208+
209+
func TestSetFavoriteModelsCreatesMissingConfig(t *testing.T) {
210+
path := filepath.Join(t.TempDir(), "zero", "config.json")
211+
212+
cfg, err := SetFavoriteModels(path, []string{"glm-5.1"})
213+
if err != nil {
214+
t.Fatalf("SetFavoriteModels() error = %v", err)
215+
}
216+
217+
if !reflect.DeepEqual(cfg.Preferences.FavoriteModels, []string{"glm-5.1"}) {
218+
t.Fatalf("FavoriteModels = %#v, want glm-5.1", cfg.Preferences.FavoriteModels)
219+
}
220+
if _, err := os.Stat(path); err != nil {
221+
t.Fatalf("expected config file to be created: %v", err)
222+
}
223+
}
224+
181225
func writeConfigFixture(t *testing.T, path string, cfg FileConfig, mode fs.FileMode) []byte {
182226
t.Helper()
183227

0 commit comments

Comments
 (0)