From e70d46fce15611861c38eeeb3ad11e42bb9fe28b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 19 Jan 2026 10:14:22 +0100 Subject: [PATCH] refactor: reasoning dialog (#1880) --- 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(-) create mode 100644 internal/ui/dialog/reasoning.go diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a4cfd66d695472e579fabf458ad5fc570af85b7e..b5db01692437dbee4b11b77da47b68f258b090e9 100644 --- a/internal/ui/dialog/actions.go +++ b/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 diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 97addbba1036781840951f2503b8b7813a16d9eb..9039a2457d3c86ba21886deac4137970f861fd59 100644 --- a/internal/ui/dialog/commands.go +++ b/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, })) } } diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..258c5c77470380478a2ffab9af89db195c849d32 --- /dev/null +++ b/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) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 581152f70ca9e2998bac8d5a593b3dd622efeea4..57253bd968b097e00e15b7c5abfe324ad64d72f4 100644 --- a/internal/ui/model/ui.go +++ b/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. diff --git a/internal/uiutil/uiutil.go b/internal/uiutil/uiutil.go index 92ae8c97937793e0643cdcb6a930216116fee2dd..d0443f9c1e4b40fc23b3fb9d597a1d0cd785e1b0 100644 --- a/internal/uiutil/uiutil.go +++ b/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 (