refactor: reasoning dialog (#1880)

Kujtim Hoxha created

Change summary

internal/ui/dialog/actions.go   |   4 
internal/ui/dialog/commands.go  |   5 
internal/ui/dialog/reasoning.go | 297 +++++++++++++++++++++++++++++++++++
internal/ui/model/ui.go         |  92 ++++++++++
internal/uiutil/uiutil.go       |  32 ++-
5 files changed, 413 insertions(+), 17 deletions(-)

Detailed changes

internal/ui/dialog/actions.go 🔗

@@ -54,6 +54,10 @@ type (
 	ActionSummarize         struct {
 		SessionID string
 	}
+	// ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
+	ActionSelectReasoningEffort struct {
+		Effort string
+	}
 	ActionPermissionResponse struct {
 		Permission permission.PermissionRequest
 		Action     PermissionAction

internal/ui/dialog/commands.go 🔗

@@ -10,6 +10,7 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -400,7 +401,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 			selectedModel := cfg.Models[agentCfg.Model]
 
 			// Anthropic models: thinking toggle
-			if providerCfg.Type == catwalk.TypeAnthropic {
+			if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
 				status := "Enable"
 				if selectedModel.Think {
 					status = "Disable"
@@ -411,7 +412,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
 			// OpenAI models: reasoning effort dialog
 			if len(model.ReasoningLevels) > 0 {
 				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
-					// TODO: Pass in the reasoning effort dialog id
+					DialogID: ReasoningID,
 				}))
 			}
 		}

internal/ui/dialog/reasoning.go 🔗

@@ -0,0 +1,297 @@
+package dialog
+
+import (
+	"errors"
+
+	"charm.land/bubbles/v2/help"
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/sahilm/fuzzy"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+const (
+	// ReasoningID is the identifier for the reasoning effort dialog.
+	ReasoningID              = "reasoning"
+	reasoningDialogMaxWidth  = 80
+	reasoningDialogMaxHeight = 12
+)
+
+// Reasoning represents a dialog for selecting reasoning effort.
+type Reasoning struct {
+	com   *common.Common
+	help  help.Model
+	list  *list.FilterableList
+	input textinput.Model
+
+	keyMap struct {
+		Select   key.Binding
+		Next     key.Binding
+		Previous key.Binding
+		UpDown   key.Binding
+		Close    key.Binding
+	}
+}
+
+// ReasoningItem represents a reasoning effort list item.
+type ReasoningItem struct {
+	effort    string
+	title     string
+	isCurrent bool
+	t         *styles.Styles
+	m         fuzzy.Match
+	cache     map[int]string
+	focused   bool
+}
+
+var (
+	_ Dialog   = (*Reasoning)(nil)
+	_ ListItem = (*ReasoningItem)(nil)
+)
+
+// NewReasoning creates a new reasoning effort dialog.
+func NewReasoning(com *common.Common) (*Reasoning, error) {
+	r := &Reasoning{com: com}
+
+	help := help.New()
+	help.Styles = com.Styles.DialogHelpStyles()
+	r.help = help
+
+	r.list = list.NewFilterableList()
+	r.list.Focus()
+
+	r.input = textinput.New()
+	r.input.SetVirtualCursor(false)
+	r.input.Placeholder = "Type to filter"
+	r.input.SetStyles(com.Styles.TextInput)
+	r.input.Focus()
+
+	r.keyMap.Select = key.NewBinding(
+		key.WithKeys("enter", "ctrl+y"),
+		key.WithHelp("enter", "confirm"),
+	)
+	r.keyMap.Next = key.NewBinding(
+		key.WithKeys("down", "ctrl+n"),
+		key.WithHelp("↓", "next item"),
+	)
+	r.keyMap.Previous = key.NewBinding(
+		key.WithKeys("up", "ctrl+p"),
+		key.WithHelp("↑", "previous item"),
+	)
+	r.keyMap.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑/↓", "choose"),
+	)
+	r.keyMap.Close = CloseKey
+
+	if err := r.setReasoningItems(); err != nil {
+		return nil, err
+	}
+
+	return r, nil
+}
+
+// ID implements Dialog.
+func (r *Reasoning) ID() string {
+	return ReasoningID
+}
+
+// HandleMsg implements [Dialog].
+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, r.keyMap.Close):
+			return ActionClose{}
+		case key.Matches(msg, r.keyMap.Previous):
+			r.list.Focus()
+			if r.list.IsSelectedFirst() {
+				r.list.SelectLast()
+				r.list.ScrollToBottom()
+				break
+			}
+			r.list.SelectPrev()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Next):
+			r.list.Focus()
+			if r.list.IsSelectedLast() {
+				r.list.SelectFirst()
+				r.list.ScrollToTop()
+				break
+			}
+			r.list.SelectNext()
+			r.list.ScrollToSelected()
+		case key.Matches(msg, r.keyMap.Select):
+			selectedItem := r.list.SelectedItem()
+			if selectedItem == nil {
+				break
+			}
+			reasoningItem, ok := selectedItem.(*ReasoningItem)
+			if !ok {
+				break
+			}
+			return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
+		default:
+			var cmd tea.Cmd
+			r.input, cmd = r.input.Update(msg)
+			value := r.input.Value()
+			r.list.SetFilter(value)
+			r.list.ScrollToTop()
+			r.list.SetSelected(0)
+			return ActionCmd{cmd}
+		}
+	}
+	return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (r *Reasoning) Cursor() *tea.Cursor {
+	return InputCursor(r.com.Styles, r.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+	t := r.com.Styles
+	width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
+	height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
+	innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+		t.Dialog.HelpView.GetVerticalFrameSize() +
+		t.Dialog.View.GetVerticalFrameSize()
+
+	r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+	r.list.SetSize(innerWidth, height-heightOffset)
+	r.help.SetWidth(innerWidth)
+
+	rc := NewRenderContext(t, width)
+	rc.Title = "Select Reasoning Effort"
+	inputView := t.Dialog.InputPrompt.Render(r.input.View())
+	rc.AddPart(inputView)
+
+	visibleCount := len(r.list.VisibleItems())
+	if r.list.Height() >= visibleCount {
+		r.list.ScrollToTop()
+	} else {
+		r.list.ScrollToSelected()
+	}
+
+	listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
+	rc.AddPart(listView)
+	rc.Help = r.help.View(r)
+
+	view := rc.Render()
+
+	cur := r.Cursor()
+	DrawCenterCursor(scr, area, view, cur)
+	return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (r *Reasoning) ShortHelp() []key.Binding {
+	return []key.Binding{
+		r.keyMap.UpDown,
+		r.keyMap.Select,
+		r.keyMap.Close,
+	}
+}
+
+// FullHelp implements [help.KeyMap].
+func (r *Reasoning) FullHelp() [][]key.Binding {
+	m := [][]key.Binding{}
+	slice := []key.Binding{
+		r.keyMap.Select,
+		r.keyMap.Next,
+		r.keyMap.Previous,
+		r.keyMap.Close,
+	}
+	for i := 0; i < len(slice); i += 4 {
+		end := min(i+4, len(slice))
+		m = append(m, slice[i:end])
+	}
+	return m
+}
+
+func (r *Reasoning) setReasoningItems() error {
+	cfg := r.com.Config()
+	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	if !ok {
+		return errors.New("agent configuration not found")
+	}
+
+	selectedModel := cfg.Models[agentCfg.Model]
+	model := cfg.GetModelByType(agentCfg.Model)
+	if model == nil {
+		return errors.New("model configuration not found")
+	}
+
+	if len(model.ReasoningLevels) == 0 {
+		return errors.New("no reasoning levels available")
+	}
+
+	currentEffort := selectedModel.ReasoningEffort
+	if currentEffort == "" {
+		currentEffort = model.DefaultReasoningEffort
+	}
+
+	caser := cases.Title(language.English)
+	items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
+	selectedIndex := 0
+	for i, effort := range model.ReasoningLevels {
+		item := &ReasoningItem{
+			effort:    effort,
+			title:     caser.String(effort),
+			isCurrent: effort == currentEffort,
+			t:         r.com.Styles,
+		}
+		items = append(items, item)
+		if effort == currentEffort {
+			selectedIndex = i
+		}
+	}
+
+	r.list.SetItems(items...)
+	r.list.SetSelected(selectedIndex)
+	r.list.ScrollToSelected()
+	return nil
+}
+
+// Filter returns the filter value for the reasoning item.
+func (r *ReasoningItem) Filter() string {
+	return r.title
+}
+
+// ID returns the unique identifier for the reasoning effort.
+func (r *ReasoningItem) ID() string {
+	return r.effort
+}
+
+// SetFocused sets the focus state of the reasoning item.
+func (r *ReasoningItem) SetFocused(focused bool) {
+	if r.focused != focused {
+		r.cache = nil
+	}
+	r.focused = focused
+}
+
+// SetMatch sets the fuzzy match for the reasoning item.
+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
+	r.cache = nil
+	r.m = m
+}
+
+// Render returns the string representation of the reasoning item.
+func (r *ReasoningItem) Render(width int) string {
+	info := ""
+	if r.isCurrent {
+		info = "current"
+	}
+	return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
+}

internal/ui/model/ui.go 🔗

@@ -925,6 +925,36 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 	case dialog.ActionToggleCompactMode:
 		cmds = append(cmds, m.toggleCompactMode())
 		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleThinking:
+		if m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			cfg := m.com.Config()
+			if cfg == nil {
+				return uiutil.ReportError(errors.New("configuration not found"))()
+			}
+
+			agentCfg, ok := cfg.Agents[config.AgentCoder]
+			if !ok {
+				return uiutil.ReportError(errors.New("agent configuration not found"))()
+			}
+
+			currentModel := cfg.Models[agentCfg.Model]
+			currentModel.Think = !currentModel.Think
+			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+				return uiutil.ReportError(err)()
+			}
+			m.com.App.UpdateAgentModel(context.TODO())
+			status := "disabled"
+			if currentModel.Think {
+				status = "enabled"
+			}
+			return uiutil.NewInfoMsg("Thinking mode " + status)
+		})
+		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
 	case dialog.ActionInitializeProject:
@@ -969,15 +999,47 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			cmds = append(cmds, uiutil.ReportError(err))
 		}
 
-		// XXX: Should this be in a separate goroutine?
-		go m.com.App.UpdateAgentModel(context.TODO())
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+
+			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+
+			return uiutil.NewInfoMsg(modelMsg)
+		})
 
-		modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
-		cmds = append(cmds, uiutil.ReportInfo(modelMsg))
 		m.dialog.CloseDialog(dialog.APIKeyInputID)
 		m.dialog.CloseDialog(dialog.OAuthID)
 		m.dialog.CloseDialog(dialog.ModelsID)
-		// TODO CHANGE
+	case dialog.ActionSelectReasoningEffort:
+		if m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+			break
+		}
+
+		cfg := m.com.Config()
+		if cfg == nil {
+			cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+			break
+		}
+
+		agentCfg, ok := cfg.Agents[config.AgentCoder]
+		if !ok {
+			cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+			break
+		}
+
+		currentModel := cfg.Models[agentCfg.Model]
+		currentModel.ReasoningEffort = msg.Effort
+		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+			cmds = append(cmds, uiutil.ReportError(err))
+			break
+		}
+
+		cmds = append(cmds, func() tea.Msg {
+			m.com.App.UpdateAgentModel(context.TODO())
+			return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+		})
+		m.dialog.CloseDialog(dialog.ReasoningID)
 	case dialog.ActionPermissionResponse:
 		m.dialog.CloseDialog(dialog.PermissionsID)
 		switch msg.Action {
@@ -2248,6 +2310,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
 		if cmd := m.openCommandsDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+	case dialog.ReasoningID:
+		if cmd := m.openReasoningDialog(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case dialog.QuitID:
 		if cmd := m.openQuitDialog(); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -2313,6 +2379,22 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 	return nil
 }
 
+// openReasoningDialog opens the reasoning effort dialog.
+func (m *UI) openReasoningDialog() tea.Cmd {
+	if m.dialog.ContainsDialog(dialog.ReasoningID) {
+		m.dialog.BringToFront(dialog.ReasoningID)
+		return nil
+	}
+
+	reasoningDialog, err := dialog.NewReasoning(m.com)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.dialog.OpenDialog(reasoningDialog)
+	return nil
+}
+
 // openSessionsDialog opens the sessions dialog. If the dialog is already open,
 // it brings it to the front. Otherwise, it will list all the sessions and open
 // the dialog.

internal/uiutil/uiutil.go 🔗

@@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
 
 func ReportError(err error) tea.Cmd {
 	slog.Error("Error reported", "error", err)
-	return CmdHandler(InfoMsg{
-		Type: InfoTypeError,
-		Msg:  err.Error(),
-	})
+	return CmdHandler(NewErrorMsg(err))
 }
 
 type InfoType int
@@ -42,18 +39,33 @@ const (
 	InfoTypeUpdate
 )
 
-func ReportInfo(info string) tea.Cmd {
-	return CmdHandler(InfoMsg{
+func NewInfoMsg(info string) InfoMsg {
+	return InfoMsg{
 		Type: InfoTypeInfo,
 		Msg:  info,
-	})
+	}
 }
 
-func ReportWarn(warn string) tea.Cmd {
-	return CmdHandler(InfoMsg{
+func NewWarnMsg(warn string) InfoMsg {
+	return InfoMsg{
 		Type: InfoTypeWarn,
 		Msg:  warn,
-	})
+	}
+}
+
+func NewErrorMsg(err error) InfoMsg {
+	return InfoMsg{
+		Type: InfoTypeError,
+		Msg:  err.Error(),
+	}
+}
+
+func ReportInfo(info string) tea.Cmd {
+	return CmdHandler(NewInfoMsg(info))
+}
+
+func ReportWarn(warn string) tea.Cmd {
+	return CmdHandler(NewWarnMsg(warn))
 }
 
 type (