@@ -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)
@@ -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
+}
@@ -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