feat: add reasoning dialog

kujtimiihoxha and Crush created

💘 Generated with Crush
Co-Authored-By: Crush <crush@charm.land>

Change summary

internal/tui/components/dialogs/commands/commands.go   |  66 +-
internal/tui/components/dialogs/reasoning/reasoning.go | 268 ++++++++++++
internal/tui/page/chat/chat.go                         |  50 ++
3 files changed, 359 insertions(+), 25 deletions(-)

Detailed changes

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -60,17 +60,18 @@ type commandDialogCmp struct {
 }
 
 type (
-	SwitchSessionsMsg     struct{}
-	NewSessionsMsg        struct{}
-	SwitchModelMsg        struct{}
-	QuitMsg               struct{}
-	OpenFilePickerMsg     struct{}
-	ToggleHelpMsg         struct{}
-	ToggleCompactModeMsg  struct{}
-	ToggleThinkingMsg     struct{}
-	OpenExternalEditorMsg struct{}
-	ToggleYoloModeMsg     struct{}
-	CompactMsg            struct {
+	SwitchSessionsMsg      struct{}
+	NewSessionsMsg         struct{}
+	SwitchModelMsg         struct{}
+	QuitMsg                struct{}
+	OpenFilePickerMsg      struct{}
+	ToggleHelpMsg          struct{}
+	ToggleCompactModeMsg   struct{}
+	ToggleThinkingMsg      struct{}
+	OpenReasoningDialogMsg struct{}
+	OpenExternalEditorMsg  struct{}
+	ToggleYoloModeMsg      struct{}
+	CompactMsg             struct {
 		SessionID string
 	}
 )
@@ -300,26 +301,41 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 		})
 	}
 
-	// Only show thinking toggle for Anthropic models that can reason
+	// Add reasoning toggle for models that support it
 	cfg := config.Get()
 	if agentCfg, ok := cfg.Agents["coder"]; ok {
 		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
 		model := cfg.GetModelByType(agentCfg.Model)
-		if providerCfg != nil && model != nil &&
-			providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
+		if providerCfg != nil && model != nil && model.CanReason {
 			selectedModel := cfg.Models[agentCfg.Model]
-			status := "Enable"
-			if selectedModel.Think {
-				status = "Disable"
+
+			// Anthropic models: thinking toggle
+			if providerCfg.Type == catwalk.TypeAnthropic {
+				status := "Enable"
+				if selectedModel.Think {
+					status = "Disable"
+				}
+				commands = append(commands, Command{
+					ID:          "toggle_thinking",
+					Title:       status + " Thinking Mode",
+					Description: "Toggle model thinking for reasoning-capable models",
+					Handler: func(cmd Command) tea.Cmd {
+						return util.CmdHandler(ToggleThinkingMsg{})
+					},
+				})
+			}
+
+			// OpenAI models: reasoning effort dialog
+			if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+				commands = append(commands, Command{
+					ID:          "select_reasoning_effort",
+					Title:       "Select Reasoning Effort",
+					Description: "Choose reasoning effort level (low/medium/high)",
+					Handler: func(cmd Command) tea.Cmd {
+						return util.CmdHandler(OpenReasoningDialogMsg{})
+					},
+				})
 			}
-			commands = append(commands, Command{
-				ID:          "toggle_thinking",
-				Title:       status + " Thinking Mode",
-				Description: "Toggle model thinking for reasoning-capable models",
-				Handler: func(cmd Command) tea.Cmd {
-					return util.CmdHandler(ToggleThinkingMsg{})
-				},
-			})
 		}
 	}
 	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)

internal/tui/components/dialogs/reasoning/reasoning.go 🔗

@@ -0,0 +1,268 @@
+package reasoning
+
+import (
+	"github.com/charmbracelet/bubbles/v2/help"
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+const (
+	ReasoningDialogID dialogs.DialogID = "reasoning"
+
+	defaultWidth int = 50
+)
+
+type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
+
+type EffortOption struct {
+	Title  string
+	Effort string
+}
+
+type ReasoningDialog interface {
+	dialogs.DialogModel
+}
+
+type reasoningDialogCmp struct {
+	width   int
+	wWidth  int // Width of the terminal window
+	wHeight int // Height of the terminal window
+
+	effortList listModel
+	keyMap     ReasoningDialogKeyMap
+	help       help.Model
+}
+
+type ReasoningEffortSelectedMsg struct {
+	Effort string
+}
+
+type ReasoningDialogKeyMap struct {
+	Next     key.Binding
+	Previous key.Binding
+	Select   key.Binding
+	Close    key.Binding
+}
+
+func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
+	return ReasoningDialogKeyMap{
+		Next: key.NewBinding(
+			key.WithKeys("down", "j", "ctrl+n"),
+			key.WithHelp("↓/j/ctrl+n", "next"),
+		),
+		Previous: key.NewBinding(
+			key.WithKeys("up", "k", "ctrl+p"),
+			key.WithHelp("↑/k/ctrl+p", "previous"),
+		),
+		Select: key.NewBinding(
+			key.WithKeys("enter"),
+			key.WithHelp("enter", "select"),
+		),
+		Close: key.NewBinding(
+			key.WithKeys("esc", "ctrl+c"),
+			key.WithHelp("esc/ctrl+c", "close"),
+		),
+	}
+}
+
+func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{k.Select, k.Close}
+}
+
+func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{k.Next, k.Previous},
+		{k.Select, k.Close},
+	}
+}
+
+func NewReasoningDialog() ReasoningDialog {
+	keyMap := DefaultReasoningDialogKeyMap()
+	listKeyMap := list.DefaultKeyMap()
+	listKeyMap.Down.SetEnabled(false)
+	listKeyMap.Up.SetEnabled(false)
+	listKeyMap.DownOneItem = keyMap.Next
+	listKeyMap.UpOneItem = keyMap.Previous
+
+	t := styles.CurrentTheme()
+	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
+	effortList := list.NewFilterableList(
+		[]list.CompletionItem[EffortOption]{},
+		list.WithFilterInputStyle(inputStyle),
+		list.WithFilterListOptions(
+			list.WithKeyMap(listKeyMap),
+			list.WithWrapNavigation(),
+			list.WithResizeByList(),
+		),
+	)
+	help := help.New()
+	help.Styles = t.S().Help
+
+	return &reasoningDialogCmp{
+		effortList: effortList,
+		width:      defaultWidth,
+		keyMap:     keyMap,
+		help:       help,
+	}
+}
+
+func (r *reasoningDialogCmp) Init() tea.Cmd {
+	return r.populateEffortOptions()
+}
+
+func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
+	cfg := config.Get()
+	if agentCfg, ok := cfg.Agents["coder"]; ok {
+		selectedModel := cfg.Models[agentCfg.Model]
+		model := cfg.GetModelByType(agentCfg.Model)
+
+		// Get current reasoning effort
+		currentEffort := selectedModel.ReasoningEffort
+		if currentEffort == "" && model != nil {
+			currentEffort = model.DefaultReasoningEffort
+		}
+
+		efforts := []EffortOption{
+			{
+				Title:  "Low",
+				Effort: "low",
+			},
+			{
+				Title:  "Medium",
+				Effort: "medium",
+			},
+			{
+				Title:  "High",
+				Effort: "high",
+			},
+		}
+
+		effortItems := []list.CompletionItem[EffortOption]{}
+		selectedID := ""
+		for _, effort := range efforts {
+			opts := []list.CompletionItemOption{
+				list.WithCompletionID(effort.Effort),
+			}
+			if effort.Effort == currentEffort {
+				opts = append(opts, list.WithCompletionShortcut("current"))
+				selectedID = effort.Effort
+			}
+			effortItems = append(effortItems, list.NewCompletionItem(
+				effort.Title,
+				effort,
+				opts...,
+			))
+		}
+
+		cmd := r.effortList.SetItems(effortItems)
+		// Set the current effort as the selected item
+		if currentEffort != "" && selectedID != "" {
+			return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
+		}
+		return cmd
+	}
+	return nil
+}
+
+func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		r.wWidth = msg.Width
+		r.wHeight = msg.Height
+		return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, r.keyMap.Select):
+			selectedItem := r.effortList.SelectedItem()
+			if selectedItem == nil {
+				return r, nil // No item selected, do nothing
+			}
+			effort := (*selectedItem).Value()
+			return r, tea.Sequence(
+				util.CmdHandler(dialogs.CloseDialogMsg{}),
+				func() tea.Msg {
+					return ReasoningEffortSelectedMsg{
+						Effort: effort.Effort,
+					}
+				},
+			)
+		case key.Matches(msg, r.keyMap.Close):
+			return r, util.CmdHandler(dialogs.CloseDialogMsg{})
+		default:
+			u, cmd := r.effortList.Update(msg)
+			r.effortList = u.(listModel)
+			return r, cmd
+		}
+	}
+	return r, nil
+}
+
+func (r *reasoningDialogCmp) View() string {
+	t := styles.CurrentTheme()
+	listView := r.effortList
+
+	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
+	content := lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		listView.View(),
+		"",
+		t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
+	)
+	return r.style().Render(content)
+}
+
+func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
+	if cursor, ok := r.effortList.(util.Cursor); ok {
+		cursor := cursor.Cursor()
+		if cursor != nil {
+			cursor = r.moveCursor(cursor)
+		}
+		return cursor
+	}
+	return nil
+}
+
+func (r *reasoningDialogCmp) listWidth() int {
+	return r.width - 2 // 4 for padding
+}
+
+func (r *reasoningDialogCmp) listHeight() int {
+	listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
+	return min(listHeight, r.wHeight/2)
+}
+
+func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
+	row, col := r.Position()
+	offset := row + 3
+	cursor.Y += offset
+	cursor.X = cursor.X + col + 2
+	return cursor
+}
+
+func (r *reasoningDialogCmp) style() lipgloss.Style {
+	t := styles.CurrentTheme()
+	return t.S().Base.
+		Width(r.width).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.BorderFocus)
+}
+
+func (r *reasoningDialogCmp) Position() (int, int) {
+	row := r.wHeight/4 - 2 // just a bit above the center
+	col := r.wWidth / 2
+	col -= r.width / 2
+	return row, col
+}
+
+func (r *reasoningDialogCmp) ID() dialogs.DialogID {
+	return ReasoningDialogID
+}

internal/tui/page/chat/chat.go 🔗

@@ -9,6 +9,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/history"
@@ -26,9 +27,11 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
 	"github.com/charmbracelet/crush/internal/tui/page"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -255,6 +258,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 	case commands.ToggleThinkingMsg:
 		return p, p.toggleThinking()
+	case commands.OpenReasoningDialogMsg:
+		return p, p.openReasoningDialog()
+	case reasoning.ReasoningEffortSelectedMsg:
+		return p, p.handleReasoningEffortSelected(msg.Effort)
 	case commands.OpenExternalEditorMsg:
 		u, cmd := p.editor.Update(msg)
 		p.editor = u.(editor.Editor)
@@ -549,6 +556,49 @@ func (p *chatPage) toggleThinking() tea.Cmd {
 	}
 }
 
+func (p *chatPage) openReasoningDialog() tea.Cmd {
+	return func() tea.Msg {
+		cfg := config.Get()
+		agentCfg := cfg.Agents["coder"]
+		model := cfg.GetModelByType(agentCfg.Model)
+		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
+
+		if providerCfg != nil && model != nil &&
+			providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
+			// Return the OpenDialogMsg directly so it bubbles up to the main TUI
+			return dialogs.OpenDialogMsg{
+				Model: reasoning.NewReasoningDialog(),
+			}
+		}
+		return nil
+	}
+}
+
+func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
+	return func() tea.Msg {
+		cfg := config.Get()
+		agentCfg := cfg.Agents["coder"]
+		currentModel := cfg.Models[agentCfg.Model]
+
+		// Update the model configuration
+		currentModel.ReasoningEffort = effort
+		cfg.Models[agentCfg.Model] = currentModel
+
+		// Update the agent with the new configuration
+		if err := p.app.UpdateAgentModel(); err != nil {
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  "Failed to update reasoning effort: " + err.Error(),
+			}
+		}
+
+		return util.InfoMsg{
+			Type: util.InfoTypeInfo,
+			Msg:  "Reasoning effort set to " + effort,
+		}
+	}
+}
+
 func (p *chatPage) setCompactMode(compact bool) {
 	if p.compact == compact {
 		return