Skip to content

Commit 29b8a49

Browse files
halfaipgclaude
andcommitted
feat: setup wizard, throbber fix, gradient dividers, stable input layout
- Interactive first-run setup wizard (provider/key/model selection with API model fetching, glue provider config for fast+smart models) - /setup command to re-enter wizard anytime - Fix triple throbber: header shows plain "streaming", notifyBar hidden when activeToolLine is visible - Turn dividers now use glue narration with accent→cyan→purple gradient (no more generic "continuing...") - Input row no longer shifts: viewport height calculation matches compose logic exactly (notifyBar vs activeToolLine exclusion) - Expanded savedConfig with glue fields, loadConfig sets GLUE_* env vars - listModels() queries OpenAI/Anthropic model listing APIs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3900a1b commit 29b8a49

7 files changed

Lines changed: 996 additions & 85 deletions

File tree

app.go

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,50 @@ import (
88
)
99

1010
// ──────────────────────────────────────────────────────────────
11-
// Root model — routes between boot and chat screens
11+
// Root model — routes between setup, boot, and chat screens
1212
// ──────────────────────────────────────────────────────────────
1313

1414
type screen int
1515

1616
const (
17-
screenBoot screen = iota
17+
screenBoot screen = iota
1818
screenChat
19+
screenSetup
1920
)
2021

2122
type appModel struct {
2223
screen screen
2324
boot bootModel
2425
chat chatModel
26+
setup setupModel
2527
config *Config
2628
}
2729

2830
func newAppModel(cfg *Config) appModel {
2931
startScreen := screenBoot
30-
if os.Getenv("CODEBASE_NOBOOT") != "" {
32+
if cfg.NeedsSetup {
33+
startScreen = screenSetup
34+
} else if os.Getenv("CODEBASE_NOBOOT") != "" {
3135
startScreen = screenChat
3236
}
3337
return appModel{
3438
screen: startScreen,
3539
boot: newBootModel(cfg),
3640
chat: newChatModel(cfg),
41+
setup: newSetupModel(),
3742
config: cfg,
3843
}
3944
}
4045

4146
func (m appModel) Init() tea.Cmd {
42-
if m.screen == screenChat {
47+
switch m.screen {
48+
case screenSetup:
49+
return m.setup.Init()
50+
case screenChat:
4351
return m.chat.Init()
52+
default:
53+
return m.boot.Init()
4454
}
45-
return m.boot.Init()
4655
}
4756

4857
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -54,13 +63,58 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5463
}
5564
}
5665

57-
// Window size goes to both screens
66+
// Window size goes to all screens
5867
if wsMsg, ok := msg.(tea.WindowSizeMsg); ok {
5968
m.boot.width = wsMsg.Width
6069
m.boot.height = wsMsg.Height
6170
}
6271

6372
switch m.screen {
73+
case screenSetup:
74+
switch msg := msg.(type) {
75+
case setupDoneMsg:
76+
// Save config
77+
if err := saveSavedConfig(msg.config); err != nil {
78+
// Continue anyway — config is in memory
79+
_ = err
80+
}
81+
// Apply to runtime config
82+
m.config.APIKey = msg.config.APIKey
83+
m.config.BaseURL = msg.config.BaseURL
84+
m.config.Model = msg.config.Model
85+
m.config.NeedsSetup = false
86+
87+
// Set glue env vars
88+
if msg.config.GlueAPIKey != "" {
89+
os.Setenv("GLUE_API_KEY", msg.config.GlueAPIKey)
90+
}
91+
if msg.config.GlueBaseURL != "" {
92+
os.Setenv("GLUE_BASE_URL", msg.config.GlueBaseURL)
93+
}
94+
if msg.config.GlueFastModel != "" {
95+
os.Setenv("GLUE_FAST_MODEL", msg.config.GlueFastModel)
96+
}
97+
if msg.config.GlueSmartModel != "" {
98+
os.Setenv("GLUE_SMART_MODEL", msg.config.GlueSmartModel)
99+
}
100+
101+
// Recreate boot and chat models with updated config
102+
m.boot = newBootModel(m.config)
103+
m.chat = newChatModel(m.config)
104+
105+
// Transition to boot screen
106+
if os.Getenv("CODEBASE_NOBOOT") != "" {
107+
m.screen = screenChat
108+
return m, m.chat.Init()
109+
}
110+
m.screen = screenBoot
111+
return m, m.boot.Init()
112+
default:
113+
var cmd tea.Cmd
114+
m.setup, cmd = m.setup.Update(msg)
115+
return m, cmd
116+
}
117+
64118
case screenBoot:
65119
switch msg.(type) {
66120
case bootDoneMsg:
@@ -78,6 +132,13 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78132
return m, cmd
79133

80134
case screenChat:
135+
// Check for setup command trigger
136+
if setupMsg, ok := msg.(enterSetupMsg); ok {
137+
_ = setupMsg
138+
m.setup = newSetupModel()
139+
m.screen = screenSetup
140+
return m, m.setup.Init()
141+
}
81142
var cmd tea.Cmd
82143
m.chat, cmd = m.chat.Update(msg)
83144
return m, cmd
@@ -88,6 +149,8 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88149

89150
func (m appModel) View() string {
90151
switch m.screen {
152+
case screenSetup:
153+
return m.setup.View()
91154
case screenBoot:
92155
return m.boot.View()
93156
case screenChat:
@@ -96,6 +159,9 @@ func (m appModel) View() string {
96159
return ""
97160
}
98161

162+
// enterSetupMsg triggers the setup wizard from a /setup command.
163+
type enterSetupMsg struct{}
164+
99165
// Cleanup gracefully shuts down background goroutines and processes.
100166
// Called from main.go's defer chain before terminal restoration.
101167
func (m *appModel) Cleanup() {

chat.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,17 @@ func (m *chatModel) rebuildViewport() {
608608
if !m.ready {
609609
return
610610
}
611-
// Dynamic elements that reduce viewport space
612-
notifyH := len(m.notify.active)
611+
// Dynamic elements that reduce viewport space — must match compose logic exactly
613612
taskH := m.taskPanelHeight()
613+
activeToolH := 0
614+
if m.state == chatStreaming {
615+
activeToolH = 1
616+
}
617+
// notifyBar is only shown when activeToolLine is NOT showing
618+
notifyH := 0
619+
if activeToolH == 0 {
620+
notifyH = len(m.notify.active)
621+
}
614622
pickerH := 0
615623
if m.state == chatPermission {
616624
pickerH = 2
@@ -619,10 +627,6 @@ func (m *chatModel) rebuildViewport() {
619627
if len(m.suggestions) > 0 && m.state == chatIdle {
620628
suggestH = 1
621629
}
622-
activeToolH := 0
623-
if m.state == chatStreaming {
624-
activeToolH = 1
625-
}
626630
// Fixed: header(1) + topSep(1) + bottomSep(1) + input(1) = 4
627631
targetH := m.height - 4 - notifyH - taskH - pickerH - suggestH - activeToolH
628632
if targetH < 5 {
@@ -917,14 +921,20 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
917921
m.turns = evt.Turn
918922
if evt.Turn > 1 {
919923
m.flushStreamingText()
920-
dividerText := "continuing..."
921-
if m.lastNarrationText != "" {
922-
dividerText = m.lastNarrationText
924+
dividerText := m.lastNarrationText
925+
// If no narration yet, generate one now from recent actions
926+
if dividerText == "" && len(m.recentActions) > 0 {
927+
dividerText = m.glue.Narrate(m.recentActions)
928+
if dividerText != "" {
929+
m.lastNarrationText = dividerText
930+
}
931+
}
932+
if dividerText == "" {
933+
dividerText = fmt.Sprintf("turn %d", evt.Turn)
923934
}
924-
line := "─── " + dividerText + " ───"
925935
m.segments = append(m.segments, segment{
926936
kind: "divider",
927-
text: "\n" + styleDim.Render(" "+line) + "\n\n",
937+
text: "\n " + renderGradientText(dividerText, activeTheme.Accent, activeTheme.Cyan, activeTheme.Purple) + "\n\n",
928938
})
929939
m.rebuildViewport()
930940
}
@@ -1137,8 +1147,7 @@ func (m chatModel) View() string {
11371147
}
11381148
switch m.state {
11391149
case chatStreaming:
1140-
activity := m.currentToolActivity()
1141-
statusParts = append(statusParts, m.spinner.View()+" "+styleMuted.Render(activity))
1150+
statusParts = append(statusParts, styleMuted.Render("streaming"))
11421151
case chatPermission:
11431152
statusParts = append(statusParts, styleWarn.Render("⚡ permission"))
11441153
case chatPlanning:
@@ -1277,7 +1286,7 @@ func (m chatModel) View() string {
12771286
if taskPanel != "" {
12781287
out.WriteString(taskPanel + "\n")
12791288
}
1280-
if notifyBar != "" {
1289+
if notifyBar != "" && activeToolLine == "" {
12811290
out.WriteString(notifyBar) // already includes trailing \n per line
12821291
}
12831292
if activeToolLine != "" {

commands.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,15 @@ func init() {
380380
return nil
381381
},
382382
},
383+
{
384+
name: "setup",
385+
desc: "Re-run the setup wizard",
386+
handler: func(m *chatModel, args string) tea.Cmd {
387+
return func() tea.Msg {
388+
return enterSetupMsg{}
389+
}
390+
},
391+
},
383392
{
384393
name: "quit",
385394
aliases: []string{"exit", "q"},

llm.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"net/http"
1111
"os"
12+
"sort"
1213
"strings"
1314
"time"
1415
)
@@ -319,6 +320,85 @@ func truncateErrorBody(s string) string {
319320
return s
320321
}
321322

323+
// ──────────────────────────────────────────────────────────────
324+
// Model listing — query provider APIs for available models
325+
// ──────────────────────────────────────────────────────────────
326+
327+
// listModels fetches available model IDs from an API provider.
328+
// protocol should be "openai" or "anthropic".
329+
func listModels(apiKey, baseURL, protocol string) ([]string, error) {
330+
baseURL = strings.TrimSuffix(baseURL, "/")
331+
client := &http.Client{Timeout: 15 * time.Second}
332+
333+
var req *http.Request
334+
var err error
335+
336+
if protocol == ProtocolAnthropic {
337+
req, err = http.NewRequest("GET", baseURL+"/v1/models", nil)
338+
if err != nil {
339+
return nil, err
340+
}
341+
req.Header.Set("x-api-key", apiKey)
342+
req.Header.Set("anthropic-version", "2023-06-01")
343+
} else {
344+
req, err = http.NewRequest("GET", baseURL+"/models", nil)
345+
if err != nil {
346+
return nil, err
347+
}
348+
req.Header.Set("Authorization", "Bearer "+apiKey)
349+
}
350+
351+
resp, err := client.Do(req)
352+
if err != nil {
353+
return nil, fmt.Errorf("connection error: %v", err)
354+
}
355+
defer resp.Body.Close()
356+
357+
if resp.StatusCode != 200 {
358+
body, _ := io.ReadAll(resp.Body)
359+
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, truncateErrorBody(string(body)))
360+
}
361+
362+
var result struct {
363+
Data []struct {
364+
ID string `json:"id"`
365+
} `json:"data"`
366+
}
367+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
368+
return nil, fmt.Errorf("decode error: %v", err)
369+
}
370+
371+
// Filter: keep only chat/completion models, skip embeddings/whisper/dall-e/tts
372+
skipPrefixes := []string{"embedding", "text-embedding", "whisper", "dall-e", "tts", "davinci", "babbage", "ada"}
373+
skipContains := []string{"embed", "whisper", "dall-e", "tts-", "moderation", "realtime"}
374+
375+
var models []string
376+
for _, m := range result.Data {
377+
id := strings.ToLower(m.ID)
378+
skip := false
379+
for _, p := range skipPrefixes {
380+
if strings.HasPrefix(id, p) {
381+
skip = true
382+
break
383+
}
384+
}
385+
if !skip {
386+
for _, c := range skipContains {
387+
if strings.Contains(id, c) {
388+
skip = true
389+
break
390+
}
391+
}
392+
}
393+
if !skip {
394+
models = append(models, m.ID)
395+
}
396+
}
397+
398+
sort.Strings(models)
399+
return models, nil
400+
}
401+
322402
// humanizeError converts raw Go/API errors into user-friendly messages.
323403
func humanizeError(err error) string {
324404
msg := err.Error()

0 commit comments

Comments
 (0)