diff --git a/cli/internal/tui/app.go b/cli/internal/tui/app.go index 4a35734..906dcaf 100644 --- a/cli/internal/tui/app.go +++ b/cli/internal/tui/app.go @@ -64,11 +64,13 @@ type modelChangedMsg struct { } type commandExecutedMsg struct { - Name string - Message string - Effort string - Err error - Gen int + Name string + Message string + Effort string + Available []string + SessionID string + Err error + Gen int } type ChatWorkspaceChangedMsg struct { @@ -404,6 +406,19 @@ func (m AppModel) executeCommandCmd(name, args string, gen int) tea.Cmd { var newEffort string if res != nil { if r, ok := res["result"].(map[string]any); ok { + var available []string + if name == "/effort" && args == "" { + if levels, ok := r["available"].([]any); ok { + for _, level := range levels { + if text, ok := level.(string); ok { + available = append(available, text) + } + } + } + } + if len(available) > 0 { + return commandExecutedMsg{Name: name, Available: available, SessionID: m.ActiveSession.ID, Gen: gen} + } if eStr, ok := r["effort"].(string); ok { newEffort = eStr } @@ -763,10 +778,14 @@ func (m AppModel) Update(msg tea.Msg) (result tea.Model, command tea.Cmd) { return m, nil case commandExecutedMsg: - if msg.Gen != m.CommandGen { + if msg.Gen != m.CommandGen || (msg.SessionID != "" && (m.ActiveSession == nil || msg.SessionID != m.ActiveSession.ID)) { return m, nil } m.ClearNotices() + if len(msg.Available) > 0 && m.State == AppStateChat { + m.Chat.openEffortSelector(msg.Available) + return m, nil + } if msg.Err != nil { m.AddError("Error: " + msg.Err.Error()) } else { diff --git a/cli/internal/tui/chat.go b/cli/internal/tui/chat.go index 1a33827..446dabe 100644 --- a/cli/internal/tui/chat.go +++ b/cli/internal/tui/chat.go @@ -7,9 +7,9 @@ import ( "fmt" "math" "math/rand/v2" - "regexp" "os" "os/exec" + "regexp" "slices" "strconv" "strings" @@ -245,26 +245,28 @@ func wrapOrChunkLine(line string, width int) []string { } type ChatModel struct { - SessionID string - Generation int64 - AgentName string - Workspace string - Model string - Effort string - Provider string - Client *daemon.ChatClient - History *BoundedHistory - Renderer TranscriptRenderer - Viewport viewport.Model - TextArea textarea.Model - CommandMenu CommandMenuModel - Flags DisplayFlags - Follow bool - TurnFailed bool - Stopping bool - Stopped bool - Status daemon.AgentStatus - Usage *daemon.Usage + SessionID string + Generation int64 + AgentName string + Workspace string + Model string + Effort string + Provider string + Client *daemon.ChatClient + History *BoundedHistory + Renderer TranscriptRenderer + Viewport viewport.Model + TextArea textarea.Model + CommandMenu CommandMenuModel + effortOptions []string + effortSelected int + Flags DisplayFlags + Follow bool + TurnFailed bool + Stopping bool + Stopped bool + Status daemon.AgentStatus + Usage *daemon.Usage // window is the context window of windowModel, read once per model so // the footer can say how full the context is. window *int @@ -310,7 +312,7 @@ type ChatModel struct { pendingUsers []PendingUserTurn isSending bool interruptDeferred bool - sentHere bool + sentHere bool WorkspaceRecovery *WorkspaceRecoveryState RecoveryInput textinput.Model @@ -499,6 +501,9 @@ func (m ChatModel) inputRows() int { if m.WorkspaceRecovery != nil { return m.WorkspaceRecovery.Rows() } + if len(m.effortOptions) > 0 { + return 4 // breathing room, title, choices, keyboard hint; no composer + } menu := min(4, len(m.CommandMenu.Matches(m.TextArea.Value()))) return m.promptHeight() + menu } @@ -513,7 +518,6 @@ func (m *ChatModel) syncLayout() { m.refreshViewportContent() } - func (m *ChatModel) SetSize(width, height int) { if m == nil || m.History == nil { return @@ -1052,6 +1056,30 @@ func (m ChatModel) update(msg tea.Msg) (ChatModel, tea.Cmd) { return m, riCmd } + // The selector owns keys while open, before chat navigation or composer input. + if len(m.effortOptions) > 0 { + switch msg.Type { + case tea.KeyLeft, tea.KeyUp: + m.effortSelected = max(0, m.effortSelected-1) + case tea.KeyRight, tea.KeyDown: + m.effortSelected = min(len(m.effortOptions)-1, m.effortSelected+1) + case tea.KeyEnter: + level := m.effortOptions[m.effortSelected] + m.effortOptions = nil + m.syncLayout() + return m, func() tea.Msg { return ChatExecuteCommandMsg{Name: "/effort", Args: level} } + case tea.KeyEsc: + m.effortOptions = nil + m.syncLayout() + case tea.KeyCtrlC: + if m.streamCancel != nil { + m.streamCancel() + } + return m, func() tea.Msg { return ChatQuitMsg{} } + } + return m, nil + } + if msg.Type == tea.KeyLeft && m.TextArea.Value() == "" { return m, func() tea.Msg { return ChatBackToSessionsMsg{} } } @@ -1940,7 +1968,6 @@ func (m *ChatModel) handleStreamEvent(evt daemon.StreamEvent) { m.closeTurn(false) } - case daemon.EventCompacted: m.settleActiveStream() m.ToolProgressText = "" @@ -2309,6 +2336,8 @@ func (m ChatModel) View() string { } else { rows = append(rows, m.Styles.Faint.Render(help)) } + } else if len(m.effortOptions) > 0 { + rows = append(rows, "", m.Styles.Bold.Render(ansi.Truncate("Reasoning effort", width, "")), m.effortSelectorView(), m.Styles.Faint.Render(ansi.Truncate("← → choose · enter apply · esc cancel", width, ""))) } else { rows = append(rows, strings.Split(strings.TrimSuffix(m.composerView(), "\n"), "\n")...) if menu := m.CommandMenu.View(m.TextArea.Value()); menu != "" { @@ -2464,4 +2493,3 @@ func shortCount(n int) string { return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "m" } } - diff --git a/cli/internal/tui/effort_request_test.go b/cli/internal/tui/effort_request_test.go new file mode 100644 index 0000000..7714db3 --- /dev/null +++ b/cli/internal/tui/effort_request_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "albedo/cli/internal/config" + "albedo/cli/internal/daemon" +) + +func TestEffortCommandUsesModelAvailableLevels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sessions/s1/commands" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["name"] != "/effort" || body["arguments"] != nil { + t.Errorf("unexpected request: %#v", body) + } + _ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"effort": "high", "available": []string{"low", "high", "max"}}}) + })) + defer server.Close() + address, _ := url.Parse(server.URL) + port, _ := strconv.Atoi(address.Port()) + session := daemon.Session{ID: "s1", Effort: "high"} + app := NewAppModel(daemon.NewConnection(daemon.ConnectionSnapshot{Port: port, Token: "t", Version: 2}, ""), config.Profiles{}, &session, "/work", false) + msg, ok := app.executeCommandCmd("/effort", "", 1)().(commandExecutedMsg) + if !ok || msg.Err != nil || len(msg.Available) != 3 || msg.Available[2] != "max" || msg.SessionID != "s1" { + t.Fatalf("unexpected response: %#v", msg) + } +} diff --git a/cli/internal/tui/effort_selector.go b/cli/internal/tui/effort_selector.go new file mode 100644 index 0000000..d57fd7d --- /dev/null +++ b/cli/internal/tui/effort_selector.go @@ -0,0 +1,93 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +// Effort choices come from the active model, not the command catalog's examples. +func (m *ChatModel) openEffortSelector(levels []string) { + m.effortOptions = append([]string(nil), levels...) + m.effortSelected = 0 + for i, level := range levels { + if level == m.Effort { + m.effortSelected = i + break + } + } + m.TextArea.Reset() + m.syncLayout() +} + +func (m ChatModel) effortSelectorView() string { + if len(m.effortOptions) == 0 { + return "" + } + width := m.chatWidth() + selected := m.effortSelected + // Keep the selected tier visible when the terminal cannot fit every tier. + left, right := 0, len(m.effortOptions) + label := func(i int) string { + if i == selected { + return " [" + m.effortOptions[i] + "] " + } + return m.effortOptions[i] + } + plain := func() string { + parts := make([]string, 0, right-left) + for i := left; i < right; i++ { + parts = append(parts, label(i)) + } + prefix, suffix := "", "" + if left > 0 { + prefix += "‹ " + } + if right < len(m.effortOptions) { + suffix = " ›" + } + return prefix + strings.Join(parts, " ─ ") + suffix + } + for ansi.StringWidth(plain()) > width && right-left > 1 { + if selected-left > right-1-selected { + left++ + } else { + right-- + } + } + parts := make([]string, 0, right-left) + for i := left; i < right; i++ { + style := m.effortTierStyle(m.effortOptions[i]) + if i == selected { + style = style.Inherit(m.Styles.Selected).Bold(true) + } + parts = append(parts, style.Render(label(i))) + } + prefix := "" + if left > 0 { + prefix += m.Styles.Decor.Render("‹ ") + } + suffix := "" + if right < len(m.effortOptions) { + suffix = m.Styles.Decor.Render(" ›") + } + return ansi.Truncate(prefix+strings.Join(parts, m.Styles.Decor.Render(" ─ "))+suffix, width, "") +} + +func (m ChatModel) effortTierStyle(level string) lipgloss.Style { + switch level { + case "low": + return m.Styles.EffortLow + case "medium": + return m.Styles.EffortMedium + case "high": + return m.Styles.EffortHigh + case "xhigh": + return m.Styles.EffortXHigh + case "max": + return m.Styles.EffortMax + default: + return m.Styles.Muted + } +} diff --git a/cli/internal/tui/effort_selector_test.go b/cli/internal/tui/effort_selector_test.go new file mode 100644 index 0000000..3762371 --- /dev/null +++ b/cli/internal/tui/effort_selector_test.go @@ -0,0 +1,67 @@ +package tui + +import ( + "strings" + "testing" + + "albedo/cli/internal/config" + "albedo/cli/internal/daemon" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +func TestEffortSelectorStaysInChatAndCommits(t *testing.T) { + session := daemon.Session{ID: "s1", Model: "model", Effort: "high"} + app := NewAppModel(nil, config.Profiles{}, &session, "/work", false) + app.Chat.SetSize(80, 22) + updated, _ := app.Update(commandExecutedMsg{Name: "/effort", Available: []string{"low", "medium", "high", "xhigh", "max"}, Gen: app.CommandGen}) + app = updated.(AppModel) + view := ansi.Strip(app.View()) + if app.State != AppStateChat || app.Chat.effortSelected != 2 || !strings.Contains(view, "[high]") { + t.Fatal("expected current tier selected within chat") + } + lines := strings.Split(view, "\n") + found := false + for i := 1; i+1 < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "Reasoning effort" && strings.TrimSpace(lines[i-1]) == "" && strings.Contains(lines[i+1], "[high]") { + found = true + } + } + if !found { + t.Fatalf("expected a spaced title and separate tiers, got:\n%s", view) + } + if strings.Contains(view, "ctrl+g editor") { + t.Fatal("composer must be hidden while choosing effort") + } + oldHeight := app.Chat.Viewport.Height + updated, _ = app.Update(tea.KeyMsg{Type: tea.KeyRight}) + app = updated.(AppModel) + if app.Chat.effortSelected != 3 || !strings.Contains(ansi.Strip(app.View()), "[xhigh]") { + t.Fatal("right did not select xhigh") + } + updated, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = updated.(AppModel) + if len(app.Chat.effortOptions) != 0 || app.Chat.Viewport.Height != oldHeight+3 { + t.Fatal("selector did not close and restore viewport") + } + got, ok := cmd().(ChatExecuteCommandMsg) + if !ok || got.Name != "/effort" || got.Args != "xhigh" { + t.Fatalf("unexpected selection: %#v", got) + } +} + +func TestEffortSelectorEscapeAndNarrowLayout(t *testing.T) { + m := NewChatModel(&daemon.Session{ID: "s1", Effort: "max"}, nil) + m.SetSize(28, 15) + m.openEffortSelector([]string{"low", "medium", "high", "xhigh", "max"}) + if line := m.effortSelectorView(); !strings.Contains(ansi.Strip(line), "[max]") || ansi.StringWidth(line) > m.chatWidth() { + t.Fatalf("selected level clipped or line too wide: %q", line) + } + m, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if cmd != nil || len(m.effortOptions) != 0 { + t.Fatal("escape did not dismiss selector") + } + if len(m.History.Entries()) != 0 { + t.Fatal("selector should not write to history") + } +} diff --git a/cli/internal/tui/theme.go b/cli/internal/tui/theme.go index fbbc766..5bf8f7a 100644 --- a/cli/internal/tui/theme.go +++ b/cli/internal/tui/theme.go @@ -44,24 +44,35 @@ type Styles struct { // Selected is the surface under a selected row: a tint where the // terminal reported its colors, reverse video where it did not. Selected lipgloss.Style + // Effort tiers form a cool-to-warm scale; max is violet. + EffortLow lipgloss.Style + EffortMedium lipgloss.Style + EffortHigh lipgloss.Style + EffortXHigh lipgloss.Style + EffortMax lipgloss.Style // Cursor is the text cursor and a drag selection. Cursor lipgloss.Style } var DefaultStyles = Styles{ - Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Faint: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - Decor: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), - Bold: lipgloss.NewStyle().Bold(true), - You: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), - Agent: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), - Busy: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Faint(true), - Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), - Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), - Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), - Selected: lipgloss.NewStyle().Reverse(true), - Cursor: lipgloss.NewStyle().Reverse(true), + Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Faint: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Decor: lipgloss.NewStyle().Foreground(lipgloss.Color("8")), + Bold: lipgloss.NewStyle().Bold(true), + You: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), + Agent: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), + Busy: lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Faint(true), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")), + Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")), + Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("6")), + Selected: lipgloss.NewStyle().Reverse(true), + EffortLow: lipgloss.NewStyle().Foreground(lipgloss.Color("#8DCFF5")), + EffortMedium: lipgloss.NewStyle().Foreground(lipgloss.Color("#78CFC3")), + EffortHigh: lipgloss.NewStyle().Foreground(lipgloss.Color("#F3AD68")), + EffortXHigh: lipgloss.NewStyle().Foreground(lipgloss.Color("#F27979")), + EffortMax: lipgloss.NewStyle().Foreground(lipgloss.Color("#C39AF5")), + Cursor: lipgloss.NewStyle().Reverse(true), } func useInk(detected ink) {