on main help sign in
refactor: reasoning dialog (#1880)
commite70d46fce15611861c38eeeb3ad11e42bb9fe28b authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 19 Jan 2026, 10:14 (9 months ago) parent181b047
5 files changed+413−17
internal/ui/dialog/actions.go+4 internal/ui/dialog/commands.go+3−2 internal/ui/dialog/reasoning.go+297 internal/ui/model/ui.go+87−5 internal/uiutil/uiutil.go+22−10
internal/ui/dialog/actions.go+4
@@ -54,6 +54,10 @@ type (
5454 ActionSummarize struct {
5555 SessionID string
5656 }
57+ // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
58+ ActionSelectReasoningEffort struct {
59+ Effort string
60+ }
5761 ActionPermissionResponse struct {
5862 Permission permission.PermissionRequest
5963 Action PermissionAction
internal/ui/dialog/commands.go+3−2
@@ -10,6 +10,7 @@ import (
1010 "charm.land/bubbles/v2/textinput"
1111 tea "charm.land/bubbletea/v2"
1212 "github.com/charmbracelet/catwalk/pkg/catwalk"
13+ "github.com/charmbracelet/crush/internal/agent/hyper"
1314 "github.com/charmbracelet/crush/internal/commands"
1415 "github.com/charmbracelet/crush/internal/config"
1516 "github.com/charmbracelet/crush/internal/ui/common"
@@ -400,7 +401,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
400401 selectedModel := cfg.Models[agentCfg.Model]
401402
402403 // Anthropic models: thinking toggle
403− if providerCfg.Type == catwalk.TypeAnthropic {
404+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
404405 status := "Enable"
405406 if selectedModel.Think {
406407 status = "Disable"
@@ -411,7 +412,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
411412 // OpenAI models: reasoning effort dialog
412413 if len(model.ReasoningLevels) > 0 {
413414 commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
414− // TODO: Pass in the reasoning effort dialog id
415+ DialogID: ReasoningID,
415416 }))
416417 }
417418 }
internal/ui/dialog/reasoning.gonew file+297
@@ -0,0 +1,297 @@
1+package dialog
2+
3+import (
4+ "errors"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/ui/common"
12+ "github.com/charmbracelet/crush/internal/ui/list"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+ uv "github.com/charmbracelet/ultraviolet"
15+ "github.com/sahilm/fuzzy"
16+ "golang.org/x/text/cases"
17+ "golang.org/x/text/language"
18+)
19+
20+const (
21+ // ReasoningID is the identifier for the reasoning effort dialog.
22+ ReasoningID = "reasoning"
23+ reasoningDialogMaxWidth = 80
24+ reasoningDialogMaxHeight = 12
25+)
26+
27+// Reasoning represents a dialog for selecting reasoning effort.
28+type Reasoning struct {
29+ com *common.Common
30+ help help.Model
31+ list *list.FilterableList
32+ input textinput.Model
33+
34+ keyMap struct {
35+ Select key.Binding
36+ Next key.Binding
37+ Previous key.Binding
38+ UpDown key.Binding
39+ Close key.Binding
40+ }
41+}
42+
43+// ReasoningItem represents a reasoning effort list item.
44+type ReasoningItem struct {
45+ effort string
46+ title string
47+ isCurrent bool
48+ t *styles.Styles
49+ m fuzzy.Match
50+ cache map[int]string
51+ focused bool
52+}
53+
54+var (
55+ _ Dialog = (*Reasoning)(nil)
56+ _ ListItem = (*ReasoningItem)(nil)
57+)
58+
59+// NewReasoning creates a new reasoning effort dialog.
60+func NewReasoning(com *common.Common) (*Reasoning, error) {
61+ r := &Reasoning{com: com}
62+
63+ help := help.New()
64+ help.Styles = com.Styles.DialogHelpStyles()
65+ r.help = help
66+
67+ r.list = list.NewFilterableList()
68+ r.list.Focus()
69+
70+ r.input = textinput.New()
71+ r.input.SetVirtualCursor(false)
72+ r.input.Placeholder = "Type to filter"
73+ r.input.SetStyles(com.Styles.TextInput)
74+ r.input.Focus()
75+
76+ r.keyMap.Select = key.NewBinding(
77+ key.WithKeys("enter", "ctrl+y"),
78+ key.WithHelp("enter", "confirm"),
79+ )
80+ r.keyMap.Next = key.NewBinding(
81+ key.WithKeys("down", "ctrl+n"),
82+ key.WithHelp("↓", "next item"),
83+ )
84+ r.keyMap.Previous = key.NewBinding(
85+ key.WithKeys("up", "ctrl+p"),
86+ key.WithHelp("↑", "previous item"),
87+ )
88+ r.keyMap.UpDown = key.NewBinding(
89+ key.WithKeys("up", "down"),
90+ key.WithHelp("↑/↓", "choose"),
91+ )
92+ r.keyMap.Close = CloseKey
93+
94+ if err := r.setReasoningItems(); err != nil {
95+ return nil, err
96+ }
97+
98+ return r, nil
99+}
100+
101+// ID implements Dialog.
102+func (r *Reasoning) ID() string {
103+ return ReasoningID
104+}
105+
106+// HandleMsg implements [Dialog].
107+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
108+ switch msg := msg.(type) {
109+ case tea.KeyPressMsg:
110+ switch {
111+ case key.Matches(msg, r.keyMap.Close):
112+ return ActionClose{}
113+ case key.Matches(msg, r.keyMap.Previous):
114+ r.list.Focus()
115+ if r.list.IsSelectedFirst() {
116+ r.list.SelectLast()
117+ r.list.ScrollToBottom()
118+ break
119+ }
120+ r.list.SelectPrev()
121+ r.list.ScrollToSelected()
122+ case key.Matches(msg, r.keyMap.Next):
123+ r.list.Focus()
124+ if r.list.IsSelectedLast() {
125+ r.list.SelectFirst()
126+ r.list.ScrollToTop()
127+ break
128+ }
129+ r.list.SelectNext()
130+ r.list.ScrollToSelected()
131+ case key.Matches(msg, r.keyMap.Select):
132+ selectedItem := r.list.SelectedItem()
133+ if selectedItem == nil {
134+ break
135+ }
136+ reasoningItem, ok := selectedItem.(*ReasoningItem)
137+ if !ok {
138+ break
139+ }
140+ return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
141+ default:
142+ var cmd tea.Cmd
143+ r.input, cmd = r.input.Update(msg)
144+ value := r.input.Value()
145+ r.list.SetFilter(value)
146+ r.list.ScrollToTop()
147+ r.list.SetSelected(0)
148+ return ActionCmd{cmd}
149+ }
150+ }
151+ return nil
152+}
153+
154+// Cursor returns the cursor position relative to the dialog.
155+func (r *Reasoning) Cursor() *tea.Cursor {
156+ return InputCursor(r.com.Styles, r.input.Cursor())
157+}
158+
159+// Draw implements [Dialog].
160+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
161+ t := r.com.Styles
162+ width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
163+ height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
164+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
165+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
166+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
167+ t.Dialog.HelpView.GetVerticalFrameSize() +
168+ t.Dialog.View.GetVerticalFrameSize()
169+
170+ r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
171+ r.list.SetSize(innerWidth, height-heightOffset)
172+ r.help.SetWidth(innerWidth)
173+
174+ rc := NewRenderContext(t, width)
175+ rc.Title = "Select Reasoning Effort"
176+ inputView := t.Dialog.InputPrompt.Render(r.input.View())
177+ rc.AddPart(inputView)
178+
179+ visibleCount := len(r.list.VisibleItems())
180+ if r.list.Height() >= visibleCount {
181+ r.list.ScrollToTop()
182+ } else {
183+ r.list.ScrollToSelected()
184+ }
185+
186+ listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
187+ rc.AddPart(listView)
188+ rc.Help = r.help.View(r)
189+
190+ view := rc.Render()
191+
192+ cur := r.Cursor()
193+ DrawCenterCursor(scr, area, view, cur)
194+ return cur
195+}
196+
197+// ShortHelp implements [help.KeyMap].
198+func (r *Reasoning) ShortHelp() []key.Binding {
199+ return []key.Binding{
200+ r.keyMap.UpDown,
201+ r.keyMap.Select,
202+ r.keyMap.Close,
203+ }
204+}
205+
206+// FullHelp implements [help.KeyMap].
207+func (r *Reasoning) FullHelp() [][]key.Binding {
208+ m := [][]key.Binding{}
209+ slice := []key.Binding{
210+ r.keyMap.Select,
211+ r.keyMap.Next,
212+ r.keyMap.Previous,
213+ r.keyMap.Close,
214+ }
215+ for i := 0; i < len(slice); i += 4 {
216+ end := min(i+4, len(slice))
217+ m = append(m, slice[i:end])
218+ }
219+ return m
220+}
221+
222+func (r *Reasoning) setReasoningItems() error {
223+ cfg := r.com.Config()
224+ agentCfg, ok := cfg.Agents[config.AgentCoder]
225+ if !ok {
226+ return errors.New("agent configuration not found")
227+ }
228+
229+ selectedModel := cfg.Models[agentCfg.Model]
230+ model := cfg.GetModelByType(agentCfg.Model)
231+ if model == nil {
232+ return errors.New("model configuration not found")
233+ }
234+
235+ if len(model.ReasoningLevels) == 0 {
236+ return errors.New("no reasoning levels available")
237+ }
238+
239+ currentEffort := selectedModel.ReasoningEffort
240+ if currentEffort == "" {
241+ currentEffort = model.DefaultReasoningEffort
242+ }
243+
244+ caser := cases.Title(language.English)
245+ items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
246+ selectedIndex := 0
247+ for i, effort := range model.ReasoningLevels {
248+ item := &ReasoningItem{
249+ effort: effort,
250+ title: caser.String(effort),
251+ isCurrent: effort == currentEffort,
252+ t: r.com.Styles,
253+ }
254+ items = append(items, item)
255+ if effort == currentEffort {
256+ selectedIndex = i
257+ }
258+ }
259+
260+ r.list.SetItems(items...)
261+ r.list.SetSelected(selectedIndex)
262+ r.list.ScrollToSelected()
263+ return nil
264+}
265+
266+// Filter returns the filter value for the reasoning item.
267+func (r *ReasoningItem) Filter() string {
268+ return r.title
269+}
270+
271+// ID returns the unique identifier for the reasoning effort.
272+func (r *ReasoningItem) ID() string {
273+ return r.effort
274+}
275+
276+// SetFocused sets the focus state of the reasoning item.
277+func (r *ReasoningItem) SetFocused(focused bool) {
278+ if r.focused != focused {
279+ r.cache = nil
280+ }
281+ r.focused = focused
282+}
283+
284+// SetMatch sets the fuzzy match for the reasoning item.
285+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
286+ r.cache = nil
287+ r.m = m
288+}
289+
290+// Render returns the string representation of the reasoning item.
291+func (r *ReasoningItem) Render(width int) string {
292+ info := ""
293+ if r.isCurrent {
294+ info = "current"
295+ }
296+ return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
297+}
internal/ui/model/ui.go+87−5
@@ -925,6 +925,36 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
925925 case dialog.ActionToggleCompactMode:
926926 cmds = append(cmds, m.toggleCompactMode())
927927 m.dialog.CloseDialog(dialog.CommandsID)
928+ case dialog.ActionToggleThinking:
929+ if m.com.App.AgentCoordinator.IsBusy() {
930+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
931+ break
932+ }
933+
934+ cmds = append(cmds, func() tea.Msg {
935+ cfg := m.com.Config()
936+ if cfg == nil {
937+ return uiutil.ReportError(errors.New("configuration not found"))()
938+ }
939+
940+ agentCfg, ok := cfg.Agents[config.AgentCoder]
941+ if !ok {
942+ return uiutil.ReportError(errors.New("agent configuration not found"))()
943+ }
944+
945+ currentModel := cfg.Models[agentCfg.Model]
946+ currentModel.Think = !currentModel.Think
947+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
948+ return uiutil.ReportError(err)()
949+ }
950+ m.com.App.UpdateAgentModel(context.TODO())
951+ status := "disabled"
952+ if currentModel.Think {
953+ status = "enabled"
954+ }
955+ return uiutil.NewInfoMsg("Thinking mode " + status)
956+ })
957+ m.dialog.CloseDialog(dialog.CommandsID)
928958 case dialog.ActionQuit:
929959 cmds = append(cmds, tea.Quit)
930960 case dialog.ActionInitializeProject:
@@ -969,15 +999,47 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
969999 cmds = append(cmds, uiutil.ReportError(err))
9701000 }
9711001
972− // XXX: Should this be in a separate goroutine?
973− go m.com.App.UpdateAgentModel(context.TODO())
1002+ cmds = append(cmds, func() tea.Msg {
1003+ m.com.App.UpdateAgentModel(context.TODO())
1004+
1005+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1006+
1007+ return uiutil.NewInfoMsg(modelMsg)
1008+ })
9741009
975− modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
976− cmds = append(cmds, uiutil.ReportInfo(modelMsg))
9771010 m.dialog.CloseDialog(dialog.APIKeyInputID)
9781011 m.dialog.CloseDialog(dialog.OAuthID)
9791012 m.dialog.CloseDialog(dialog.ModelsID)
980− // TODO CHANGE
1013+ case dialog.ActionSelectReasoningEffort:
1014+ if m.com.App.AgentCoordinator.IsBusy() {
1015+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1016+ break
1017+ }
1018+
1019+ cfg := m.com.Config()
1020+ if cfg == nil {
1021+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1022+ break
1023+ }
1024+
1025+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1026+ if !ok {
1027+ cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
1028+ break
1029+ }
1030+
1031+ currentModel := cfg.Models[agentCfg.Model]
1032+ currentModel.ReasoningEffort = msg.Effort
1033+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1034+ cmds = append(cmds, uiutil.ReportError(err))
1035+ break
1036+ }
1037+
1038+ cmds = append(cmds, func() tea.Msg {
1039+ m.com.App.UpdateAgentModel(context.TODO())
1040+ return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1041+ })
1042+ m.dialog.CloseDialog(dialog.ReasoningID)
9811043 case dialog.ActionPermissionResponse:
9821044 m.dialog.CloseDialog(dialog.PermissionsID)
9831045 switch msg.Action {
@@ -2248,6 +2310,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
22482310 if cmd := m.openCommandsDialog(); cmd != nil {
22492311 cmds = append(cmds, cmd)
22502312 }
2313+ case dialog.ReasoningID:
2314+ if cmd := m.openReasoningDialog(); cmd != nil {
2315+ cmds = append(cmds, cmd)
2316+ }
22512317 case dialog.QuitID:
22522318 if cmd := m.openQuitDialog(); cmd != nil {
22532319 cmds = append(cmds, cmd)
@@ -2313,6 +2379,22 @@ func (m *UI) openCommandsDialog() tea.Cmd {
23132379 return nil
23142380}
23152381
2382+// openReasoningDialog opens the reasoning effort dialog.
2383+func (m *UI) openReasoningDialog() tea.Cmd {
2384+ if m.dialog.ContainsDialog(dialog.ReasoningID) {
2385+ m.dialog.BringToFront(dialog.ReasoningID)
2386+ return nil
2387+ }
2388+
2389+ reasoningDialog, err := dialog.NewReasoning(m.com)
2390+ if err != nil {
2391+ return uiutil.ReportError(err)
2392+ }
2393+
2394+ m.dialog.OpenDialog(reasoningDialog)
2395+ return nil
2396+}
2397+
23162398// openSessionsDialog opens the sessions dialog. If the dialog is already open,
23172399// it brings it to the front. Otherwise, it will list all the sessions and open
23182400// the dialog.
internal/uiutil/uiutil.go+22−10
@@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
2626
2727func ReportError(err error) tea.Cmd {
2828 slog.Error("Error reported", "error", err)
29− return CmdHandler(InfoMsg{
30− Type: InfoTypeError,
31− Msg: err.Error(),
32− })
29+ return CmdHandler(NewErrorMsg(err))
3330}
3431
3532type InfoType int
@@ -42,18 +39,33 @@ const (
4239 InfoTypeUpdate
4340)
4441
45−func ReportInfo(info string) tea.Cmd {
46− return CmdHandler(InfoMsg{
42+func NewInfoMsg(info string) InfoMsg {
43+ return InfoMsg{
4744 Type: InfoTypeInfo,
4845 Msg: info,
49− })
46+ }
5047}
5148
52−func ReportWarn(warn string) tea.Cmd {
53− return CmdHandler(InfoMsg{
49+func NewWarnMsg(warn string) InfoMsg {
50+ return InfoMsg{
5451 Type: InfoTypeWarn,
5552 Msg: warn,
56− })
53+ }
54+}
55+
56+func NewErrorMsg(err error) InfoMsg {
57+ return InfoMsg{
58+ Type: InfoTypeError,
59+ Msg: err.Error(),
60+ }
61+}
62+
63+func ReportInfo(info string) tea.Cmd {
64+ return CmdHandler(NewInfoMsg(info))
65+}
66+
67+func ReportWarn(warn string) tea.Cmd {
68+ return CmdHandler(NewWarnMsg(warn))
5769}
5870
5971type (