1package reasoning
2
3import (
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
17const (
18 ReasoningDialogID dialogs.DialogID = "reasoning"
19
20 defaultWidth int = 50
21)
22
23type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
24
25type EffortOption struct {
26 Title string
27 Effort string
28}
29
30type ReasoningDialog interface {
31 dialogs.DialogModel
32}
33
34type 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
44type ReasoningEffortSelectedMsg struct {
45 Effort string
46}
47
48type ReasoningDialogKeyMap struct {
49 Next key.Binding
50 Previous key.Binding
51 Select key.Binding
52 Close key.Binding
53}
54
55func 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
76func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
77 return []key.Binding{k.Select, k.Close}
78}
79
80func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
81 return [][]key.Binding{
82 {k.Next, k.Previous},
83 {k.Select, k.Close},
84 }
85}
86
87func 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
117func (r *reasoningDialogCmp) Init() tea.Cmd {
118 return r.populateEffortOptions()
119}
120
121func (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
175func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.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
208func (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
223func (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
234func (r *reasoningDialogCmp) listWidth() int {
235 return r.width - 2 // 4 for padding
236}
237
238func (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
243func (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
251func (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
259func (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
266func (r *reasoningDialogCmp) ID() dialogs.DialogID {
267 return ReasoningDialogID
268}