1package dialog
2
3import (
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)
17
18const (
19 // ReasoningID is the identifier for the reasoning effort dialog.
20 ReasoningID = "reasoning"
21 reasoningDialogMaxWidth = 50
22 reasoningDialogMaxHeight = 10
23)
24
25// Reasoning represents a dialog for selecting reasoning effort.
26type Reasoning struct {
27 com *common.Common
28 help help.Model
29 list *list.FilterableList
30 input textinput.Model
31
32 keyMap struct {
33 Select key.Binding
34 Next key.Binding
35 Previous key.Binding
36 UpDown key.Binding
37 Close key.Binding
38 }
39}
40
41// ReasoningItem represents a reasoning effort list item.
42type ReasoningItem struct {
43 *list.Versioned
44 effort string
45 title string
46 isCurrent bool
47 t *styles.Styles
48 m fuzzy.Match
49 cache map[int]string
50 focused bool
51}
52
53// Finished implements list.Item. Reasoning items are render-stable
54// outside of explicit SetFocused / SetMatch.
55func (r *ReasoningItem) Finished() bool {
56 return true
57}
58
59var (
60 _ Dialog = (*Reasoning)(nil)
61 _ ListItem = (*ReasoningItem)(nil)
62)
63
64// NewReasoning creates a new reasoning effort dialog.
65func NewReasoning(com *common.Common) (*Reasoning, error) {
66 r := &Reasoning{com: com}
67
68 help := help.New()
69 help.Styles = com.Styles.DialogHelpStyles()
70 r.help = help
71
72 r.list = list.NewFilterableList()
73 r.list.Focus()
74
75 r.input = textinput.New()
76 r.input.SetVirtualCursor(false)
77 r.input.Placeholder = "Type to filter"
78 r.input.SetStyles(com.Styles.TextInput)
79 r.input.Focus()
80
81 r.keyMap.Select = key.NewBinding(
82 key.WithKeys("enter", "ctrl+y"),
83 key.WithHelp("enter", "confirm"),
84 )
85 r.keyMap.Next = key.NewBinding(
86 key.WithKeys("down", "ctrl+n"),
87 key.WithHelp("↓", "next item"),
88 )
89 r.keyMap.Previous = key.NewBinding(
90 key.WithKeys("up", "ctrl+p"),
91 key.WithHelp("↑", "previous item"),
92 )
93 r.keyMap.UpDown = key.NewBinding(
94 key.WithKeys("up", "down"),
95 key.WithHelp("↑/↓", "choose"),
96 )
97 r.keyMap.Close = CloseKey
98
99 if err := r.setReasoningItems(); err != nil {
100 return nil, err
101 }
102
103 return r, nil
104}
105
106// ID implements Dialog.
107func (r *Reasoning) ID() string {
108 return ReasoningID
109}
110
111// HandleMsg implements [Dialog].
112func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
113 switch msg := msg.(type) {
114 case tea.KeyPressMsg:
115 switch {
116 case key.Matches(msg, r.keyMap.Close):
117 return ActionClose{}
118 case key.Matches(msg, r.keyMap.Previous):
119 r.list.Focus()
120 if r.list.IsSelectedFirst() {
121 r.list.SelectLast()
122 r.list.ScrollToBottom()
123 break
124 }
125 r.list.SelectPrev()
126 r.list.ScrollToSelected()
127 case key.Matches(msg, r.keyMap.Next):
128 r.list.Focus()
129 if r.list.IsSelectedLast() {
130 r.list.SelectFirst()
131 r.list.ScrollToTop()
132 break
133 }
134 r.list.SelectNext()
135 r.list.ScrollToSelected()
136 case key.Matches(msg, r.keyMap.Select):
137 selectedItem := r.list.SelectedItem()
138 if selectedItem == nil {
139 break
140 }
141 reasoningItem, ok := selectedItem.(*ReasoningItem)
142 if !ok {
143 break
144 }
145 return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
146 default:
147 var cmd tea.Cmd
148 r.input, cmd = r.input.Update(msg)
149 value := r.input.Value()
150 r.list.SetFilter(value)
151 r.list.ScrollToTop()
152 r.list.SetSelected(0)
153 return ActionCmd{cmd}
154 }
155 }
156 return nil
157}
158
159// Cursor returns the cursor position relative to the dialog.
160func (r *Reasoning) Cursor() *tea.Cursor {
161 return InputCursor(r.com.Styles, r.input.Cursor())
162}
163
164// Draw implements [Dialog].
165func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
166 t := r.com.Styles
167 width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
168 height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
169 innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
170 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
171 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
172 t.Dialog.HelpView.GetVerticalFrameSize() +
173 t.Dialog.View.GetVerticalFrameSize()
174
175 r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
176 r.list.SetSize(innerWidth, height-heightOffset)
177 r.help.SetWidth(innerWidth)
178
179 rc := NewRenderContext(t, width)
180 rc.Title = "Select Reasoning Effort"
181 inputView := t.Dialog.InputPrompt.Render(r.input.View())
182 rc.AddPart(inputView)
183
184 visibleCount := len(r.list.FilteredItems())
185 if r.list.Height() >= visibleCount {
186 r.list.ScrollToTop()
187 } else {
188 r.list.ScrollToSelected()
189 }
190
191 listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
192 rc.AddPart(listView)
193 rc.Help = r.help.View(r)
194
195 view := rc.Render()
196
197 cur := r.Cursor()
198 DrawCenterCursor(scr, area, view, cur)
199 return cur
200}
201
202// ShortHelp implements [help.KeyMap].
203func (r *Reasoning) ShortHelp() []key.Binding {
204 return []key.Binding{
205 r.keyMap.UpDown,
206 r.keyMap.Select,
207 r.keyMap.Close,
208 }
209}
210
211// FullHelp implements [help.KeyMap].
212func (r *Reasoning) FullHelp() [][]key.Binding {
213 m := [][]key.Binding{}
214 slice := []key.Binding{
215 r.keyMap.Select,
216 r.keyMap.Next,
217 r.keyMap.Previous,
218 r.keyMap.Close,
219 }
220 for i := 0; i < len(slice); i += 4 {
221 end := min(i+4, len(slice))
222 m = append(m, slice[i:end])
223 }
224 return m
225}
226
227func (r *Reasoning) setReasoningItems() error {
228 cfg := r.com.Config()
229 agentCfg, ok := cfg.Agents[config.AgentCoder]
230 if !ok {
231 return errors.New("agent configuration not found")
232 }
233
234 selectedModel := cfg.Models[agentCfg.Model]
235 model := cfg.GetModelByType(agentCfg.Model)
236 if model == nil {
237 return errors.New("model configuration not found")
238 }
239
240 if len(model.ReasoningLevels) == 0 {
241 return errors.New("no reasoning levels available")
242 }
243
244 currentEffort := selectedModel.ReasoningEffort
245 if currentEffort == "" {
246 currentEffort = model.DefaultReasoningEffort
247 }
248
249 items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
250 selectedIndex := 0
251 for i, effort := range model.ReasoningLevels {
252 item := &ReasoningItem{
253 Versioned: list.NewVersioned(),
254 effort: effort,
255 title: common.FormatReasoningEffort(effort),
256 isCurrent: effort == currentEffort,
257 t: r.com.Styles,
258 }
259 items = append(items, item)
260 if effort == currentEffort {
261 selectedIndex = i
262 }
263 }
264
265 r.list.SetItems(items...)
266 r.list.SetSelected(selectedIndex)
267 r.list.ScrollToSelected()
268 return nil
269}
270
271// Filter returns the filter value for the reasoning item.
272func (r *ReasoningItem) Filter() string {
273 return r.title
274}
275
276// ID returns the unique identifier for the reasoning effort.
277func (r *ReasoningItem) ID() string {
278 return r.effort
279}
280
281// SetFocused sets the focus state of the reasoning item.
282func (r *ReasoningItem) SetFocused(focused bool) {
283 if r.focused == focused {
284 return
285 }
286 r.cache = nil
287 r.focused = focused
288 if r.Versioned != nil {
289 r.Bump()
290 }
291}
292
293// SetMatch sets the fuzzy match for the reasoning item.
294func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
295 if sameFuzzyMatch(r.m, m) {
296 return
297 }
298 r.cache = nil
299 r.m = m
300 if r.Versioned != nil {
301 r.Bump()
302 }
303}
304
305// Render returns the string representation of the reasoning item.
306func (r *ReasoningItem) Render(width int) string {
307 info := ""
308 if r.isCurrent {
309 info = "current"
310 }
311 styles := ListItemStyles{
312 ItemBlurred: r.t.Dialog.NormalItem,
313 ItemFocused: r.t.Dialog.SelectedItem,
314 InfoTextBlurred: r.t.Dialog.ListItem.InfoBlurred,
315 InfoTextFocused: r.t.Dialog.ListItem.InfoFocused,
316 }
317 return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
318}