diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 756e687c693da971e9ddd8bb72f08b9fc23eedae..664158fc392a87d8a7725bfa964748f7ef4f8e67 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/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) diff --git a/internal/tui/components/dialogs/reasoning/reasoning.go b/internal/tui/components/dialogs/reasoning/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..ba49abd8c58a0e7eb84235e7b68f5f5193a96b1b --- /dev/null +++ b/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 +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index c843ce7b8cf3702eac4a9ce1b081204fe73f05c5..88523388e31824a65d7e9922b89a1886a5fbcc0d 100644 --- a/internal/tui/page/chat/chat.go +++ b/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