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