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 "golang.org/x/text/cases"
17 "golang.org/x/text/language"
18)
19
20const (
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.
28type 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.
44type 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
54var (
55 _ Dialog = (*Reasoning)(nil)
56 _ ListItem = (*ReasoningItem)(nil)
57)
58
59// NewReasoning creates a new reasoning effort dialog.
60func 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.
102func (r *Reasoning) ID() string {
103 return ReasoningID
104}
105
106// HandleMsg implements [Dialog].
107func (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.
155func (r *Reasoning) Cursor() *tea.Cursor {
156 return InputCursor(r.com.Styles, r.input.Cursor())
157}
158
159// Draw implements [Dialog].
160func (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.FilteredItems())
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].
198func (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].
207func (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
222func (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.
267func (r *ReasoningItem) Filter() string {
268 return r.title
269}
270
271// ID returns the unique identifier for the reasoning effort.
272func (r *ReasoningItem) ID() string {
273 return r.effort
274}
275
276// SetFocused sets the focus state of the reasoning item.
277func (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.
285func (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.
291func (r *ReasoningItem) Render(width int) string {
292 info := ""
293 if r.isCurrent {
294 info = "current"
295 }
296 styles := ListIemStyles{
297 ItemBlurred: r.t.Dialog.NormalItem,
298 ItemFocused: r.t.Dialog.SelectedItem,
299 InfoTextBlurred: r.t.Base,
300 InfoTextFocused: r.t.Subtle,
301 }
302 return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m)
303}