on main help sign in
feat: add reasoning dialog

๐Ÿ’˜ Generated with Crush Co-Authored-By: Crush <crush@charm.land>

commit3710b3c683f3c2acfde9ca103d517fa7a110a6d2 authorkujtimiihoxha <kujtimii.h@gmail.com> dateWed 10 Sep 2025, 18:46 (1 year ago) parentdc5475f
3 files changed+359โˆ’25
internal/tui/components/dialogs/commands/commands.go+41โˆ’25 internal/tui/components/dialogs/reasoning/reasoning.go+268 internal/tui/page/chat/chat.go+50
internal/tui/components/dialogs/commands/commands.go+41โˆ’25
@@ -60,17 +60,18 @@ type commandDialogCmp struct {
6060}
6161
6262type (
63โˆ’ SwitchSessionsMsg struct{}
64โˆ’ NewSessionsMsg struct{}
65โˆ’ SwitchModelMsg struct{}
66โˆ’ QuitMsg struct{}
67โˆ’ OpenFilePickerMsg struct{}
68โˆ’ ToggleHelpMsg struct{}
69โˆ’ ToggleCompactModeMsg struct{}
70โˆ’ ToggleThinkingMsg struct{}
71โˆ’ OpenExternalEditorMsg struct{}
72โˆ’ ToggleYoloModeMsg struct{}
73โˆ’ CompactMsg struct {
63+ SwitchSessionsMsg struct{}
64+ NewSessionsMsg struct{}
65+ SwitchModelMsg struct{}
66+ QuitMsg struct{}
67+ OpenFilePickerMsg struct{}
68+ ToggleHelpMsg struct{}
69+ ToggleCompactModeMsg struct{}
70+ ToggleThinkingMsg struct{}
71+ OpenReasoningDialogMsg struct{}
72+ OpenExternalEditorMsg struct{}
73+ ToggleYoloModeMsg struct{}
74+ CompactMsg struct {
7475 SessionID string
7576 }
7677)
@@ -300,26 +301,41 @@ func (c *commandDialogCmp) defaultCommands() []Command {
300301 })
301302 }
302303
303โˆ’ // Only show thinking toggle for Anthropic models that can reason
304+ // Add reasoning toggle for models that support it
304305 cfg := config.Get()
305306 if agentCfg, ok := cfg.Agents["coder"]; ok {
306307 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
307308 model := cfg.GetModelByType(agentCfg.Model)
308โˆ’ if providerCfg != nil && model != nil &&
309โˆ’ providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
309+ if providerCfg != nil && model != nil && model.CanReason {
310310 selectedModel := cfg.Models[agentCfg.Model]
311โˆ’ status := "Enable"
312โˆ’ if selectedModel.Think {
313โˆ’ status = "Disable"
311+
312+ // Anthropic models: thinking toggle
313+ if providerCfg.Type == catwalk.TypeAnthropic {
314+ status := "Enable"
315+ if selectedModel.Think {
316+ status = "Disable"
317+ }
318+ commands = append(commands, Command{
319+ ID: "toggle_thinking",
320+ Title: status + " Thinking Mode",
321+ Description: "Toggle model thinking for reasoning-capable models",
322+ Handler: func(cmd Command) tea.Cmd {
323+ return util.CmdHandler(ToggleThinkingMsg{})
324+ },
325+ })
326+ }
327+
328+ // OpenAI models: reasoning effort dialog
329+ if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
330+ commands = append(commands, Command{
331+ ID: "select_reasoning_effort",
332+ Title: "Select Reasoning Effort",
333+ Description: "Choose reasoning effort level (low/medium/high)",
334+ Handler: func(cmd Command) tea.Cmd {
335+ return util.CmdHandler(OpenReasoningDialogMsg{})
336+ },
337+ })
314338 }
315โˆ’ commands = append(commands, Command{
316โˆ’ ID: "toggle_thinking",
317โˆ’ Title: status + " Thinking Mode",
318โˆ’ Description: "Toggle model thinking for reasoning-capable models",
319โˆ’ Handler: func(cmd Command) tea.Cmd {
320โˆ’ return util.CmdHandler(ToggleThinkingMsg{})
321โˆ’ },
322โˆ’ })
323339 }
324340 }
325341 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
internal/tui/components/dialogs/reasoning/reasoning.gonew file+268
@@ -0,0 +1,268 @@
1+package reasoning
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/help"
5+ "github.com/charmbracelet/bubbles/v2/key"
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/lipgloss/v2"
8+
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/tui/components/core"
11+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12+ "github.com/charmbracelet/crush/internal/tui/exp/list"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/crush/internal/tui/util"
15+)
16+
17+const (
18+ ReasoningDialogID dialogs.DialogID = "reasoning"
19+
20+ defaultWidth int = 50
21+)
22+
23+type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
24+
25+type EffortOption struct {
26+ Title string
27+ Effort string
28+}
29+
30+type ReasoningDialog interface {
31+ dialogs.DialogModel
32+}
33+
34+type reasoningDialogCmp struct {
35+ width int
36+ wWidth int // Width of the terminal window
37+ wHeight int // Height of the terminal window
38+
39+ effortList listModel
40+ keyMap ReasoningDialogKeyMap
41+ help help.Model
42+}
43+
44+type ReasoningEffortSelectedMsg struct {
45+ Effort string
46+}
47+
48+type ReasoningDialogKeyMap struct {
49+ Next key.Binding
50+ Previous key.Binding
51+ Select key.Binding
52+ Close key.Binding
53+}
54+
55+func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
56+ return ReasoningDialogKeyMap{
57+ Next: key.NewBinding(
58+ key.WithKeys("down", "j", "ctrl+n"),
59+ key.WithHelp("โ†“/j/ctrl+n", "next"),
60+ ),
61+ Previous: key.NewBinding(
62+ key.WithKeys("up", "k", "ctrl+p"),
63+ key.WithHelp("โ†‘/k/ctrl+p", "previous"),
64+ ),
65+ Select: key.NewBinding(
66+ key.WithKeys("enter"),
67+ key.WithHelp("enter", "select"),
68+ ),
69+ Close: key.NewBinding(
70+ key.WithKeys("esc", "ctrl+c"),
71+ key.WithHelp("esc/ctrl+c", "close"),
72+ ),
73+ }
74+}
75+
76+func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
77+ return []key.Binding{k.Select, k.Close}
78+}
79+
80+func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
81+ return [][]key.Binding{
82+ {k.Next, k.Previous},
83+ {k.Select, k.Close},
84+ }
85+}
86+
87+func NewReasoningDialog() ReasoningDialog {
88+ keyMap := DefaultReasoningDialogKeyMap()
89+ listKeyMap := list.DefaultKeyMap()
90+ listKeyMap.Down.SetEnabled(false)
91+ listKeyMap.Up.SetEnabled(false)
92+ listKeyMap.DownOneItem = keyMap.Next
93+ listKeyMap.UpOneItem = keyMap.Previous
94+
95+ t := styles.CurrentTheme()
96+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
97+ effortList := list.NewFilterableList(
98+ []list.CompletionItem[EffortOption]{},
99+ list.WithFilterInputStyle(inputStyle),
100+ list.WithFilterListOptions(
101+ list.WithKeyMap(listKeyMap),
102+ list.WithWrapNavigation(),
103+ list.WithResizeByList(),
104+ ),
105+ )
106+ help := help.New()
107+ help.Styles = t.S().Help
108+
109+ return &reasoningDialogCmp{
110+ effortList: effortList,
111+ width: defaultWidth,
112+ keyMap: keyMap,
113+ help: help,
114+ }
115+}
116+
117+func (r *reasoningDialogCmp) Init() tea.Cmd {
118+ return r.populateEffortOptions()
119+}
120+
121+func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
122+ cfg := config.Get()
123+ if agentCfg, ok := cfg.Agents["coder"]; ok {
124+ selectedModel := cfg.Models[agentCfg.Model]
125+ model := cfg.GetModelByType(agentCfg.Model)
126+
127+ // Get current reasoning effort
128+ currentEffort := selectedModel.ReasoningEffort
129+ if currentEffort == "" && model != nil {
130+ currentEffort = model.DefaultReasoningEffort
131+ }
132+
133+ efforts := []EffortOption{
134+ {
135+ Title: "Low",
136+ Effort: "low",
137+ },
138+ {
139+ Title: "Medium",
140+ Effort: "medium",
141+ },
142+ {
143+ Title: "High",
144+ Effort: "high",
145+ },
146+ }
147+
148+ effortItems := []list.CompletionItem[EffortOption]{}
149+ selectedID := ""
150+ for _, effort := range efforts {
151+ opts := []list.CompletionItemOption{
152+ list.WithCompletionID(effort.Effort),
153+ }
154+ if effort.Effort == currentEffort {
155+ opts = append(opts, list.WithCompletionShortcut("current"))
156+ selectedID = effort.Effort
157+ }
158+ effortItems = append(effortItems, list.NewCompletionItem(
159+ effort.Title,
160+ effort,
161+ opts...,
162+ ))
163+ }
164+
165+ cmd := r.effortList.SetItems(effortItems)
166+ // Set the current effort as the selected item
167+ if currentEffort != "" && selectedID != "" {
168+ return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
169+ }
170+ return cmd
171+ }
172+ return nil
173+}
174+
175+func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
176+ switch msg := msg.(type) {
177+ case tea.WindowSizeMsg:
178+ r.wWidth = msg.Width
179+ r.wHeight = msg.Height
180+ return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
181+ case tea.KeyPressMsg:
182+ switch {
183+ case key.Matches(msg, r.keyMap.Select):
184+ selectedItem := r.effortList.SelectedItem()
185+ if selectedItem == nil {
186+ return r, nil // No item selected, do nothing
187+ }
188+ effort := (*selectedItem).Value()
189+ return r, tea.Sequence(
190+ util.CmdHandler(dialogs.CloseDialogMsg{}),
191+ func() tea.Msg {
192+ return ReasoningEffortSelectedMsg{
193+ Effort: effort.Effort,
194+ }
195+ },
196+ )
197+ case key.Matches(msg, r.keyMap.Close):
198+ return r, util.CmdHandler(dialogs.CloseDialogMsg{})
199+ default:
200+ u, cmd := r.effortList.Update(msg)
201+ r.effortList = u.(listModel)
202+ return r, cmd
203+ }
204+ }
205+ return r, nil
206+}
207+
208+func (r *reasoningDialogCmp) View() string {
209+ t := styles.CurrentTheme()
210+ listView := r.effortList
211+
212+ header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
213+ content := lipgloss.JoinVertical(
214+ lipgloss.Left,
215+ header,
216+ listView.View(),
217+ "",
218+ t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
219+ )
220+ return r.style().Render(content)
221+}
222+
223+func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
224+ if cursor, ok := r.effortList.(util.Cursor); ok {
225+ cursor := cursor.Cursor()
226+ if cursor != nil {
227+ cursor = r.moveCursor(cursor)
228+ }
229+ return cursor
230+ }
231+ return nil
232+}
233+
234+func (r *reasoningDialogCmp) listWidth() int {
235+ return r.width - 2 // 4 for padding
236+}
237+
238+func (r *reasoningDialogCmp) listHeight() int {
239+ listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
240+ return min(listHeight, r.wHeight/2)
241+}
242+
243+func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
244+ row, col := r.Position()
245+ offset := row + 3
246+ cursor.Y += offset
247+ cursor.X = cursor.X + col + 2
248+ return cursor
249+}
250+
251+func (r *reasoningDialogCmp) style() lipgloss.Style {
252+ t := styles.CurrentTheme()
253+ return t.S().Base.
254+ Width(r.width).
255+ Border(lipgloss.RoundedBorder()).
256+ BorderForeground(t.BorderFocus)
257+}
258+
259+func (r *reasoningDialogCmp) Position() (int, int) {
260+ row := r.wHeight/4 - 2 // just a bit above the center
261+ col := r.wWidth / 2
262+ col -= r.width / 2
263+ return row, col
264+}
265+
266+func (r *reasoningDialogCmp) ID() dialogs.DialogID {
267+ return ReasoningDialogID
268+}
internal/tui/page/chat/chat.go+50
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/bubbles/v2/key"
1010 "github.com/charmbracelet/bubbles/v2/spinner"
1111 tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/app"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/history"
@@ -26,9 +27,11 @@ import (
2627 "github.com/charmbracelet/crush/internal/tui/components/completions"
2728 "github.com/charmbracelet/crush/internal/tui/components/core"
2829 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
30+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
2931 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
3032 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
3133 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
34+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
3235 "github.com/charmbracelet/crush/internal/tui/page"
3336 "github.com/charmbracelet/crush/internal/tui/styles"
3437 "github.com/charmbracelet/crush/internal/tui/util"
@@ -255,6 +258,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255258 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
256259 case commands.ToggleThinkingMsg:
257260 return p, p.toggleThinking()
261+ case commands.OpenReasoningDialogMsg:
262+ return p, p.openReasoningDialog()
263+ case reasoning.ReasoningEffortSelectedMsg:
264+ return p, p.handleReasoningEffortSelected(msg.Effort)
258265 case commands.OpenExternalEditorMsg:
259266 u, cmd := p.editor.Update(msg)
260267 p.editor = u.(editor.Editor)
@@ -549,6 +556,49 @@ func (p *chatPage) toggleThinking() tea.Cmd {
549556 }
550557}
551558
559+func (p *chatPage) openReasoningDialog() tea.Cmd {
560+ return func() tea.Msg {
561+ cfg := config.Get()
562+ agentCfg := cfg.Agents["coder"]
563+ model := cfg.GetModelByType(agentCfg.Model)
564+ providerCfg := cfg.GetProviderForModel(agentCfg.Model)
565+
566+ if providerCfg != nil && model != nil &&
567+ providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
568+ // Return the OpenDialogMsg directly so it bubbles up to the main TUI
569+ return dialogs.OpenDialogMsg{
570+ Model: reasoning.NewReasoningDialog(),
571+ }
572+ }
573+ return nil
574+ }
575+}
576+
577+func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
578+ return func() tea.Msg {
579+ cfg := config.Get()
580+ agentCfg := cfg.Agents["coder"]
581+ currentModel := cfg.Models[agentCfg.Model]
582+
583+ // Update the model configuration
584+ currentModel.ReasoningEffort = effort
585+ cfg.Models[agentCfg.Model] = currentModel
586+
587+ // Update the agent with the new configuration
588+ if err := p.app.UpdateAgentModel(); err != nil {
589+ return util.InfoMsg{
590+ Type: util.InfoTypeError,
591+ Msg: "Failed to update reasoning effort: " + err.Error(),
592+ }
593+ }
594+
595+ return util.InfoMsg{
596+ Type: util.InfoTypeInfo,
597+ Msg: "Reasoning effort set to " + effort,
598+ }
599+ }
600+}
601+
552602func (p *chatPage) setCompactMode(compact bool) {
553603 if p.compact == compact {
554604 return