@@ -0,0 +1,297 @@
1+package dialog
2+
3+import (
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+
20+const (
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.
28+type 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.
44+type 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+
54+var (
55+ _ Dialog = (*Reasoning)(nil)
56+ _ ListItem = (*ReasoningItem)(nil)
57+)
58+
59+// NewReasoning creates a new reasoning effort dialog.
60+func 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.
102+func (r *Reasoning) ID() string {
103+ return ReasoningID
104+}
105+
106+// HandleMsg implements [Dialog].
107+func (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.
155+func (r *Reasoning) Cursor() *tea.Cursor {
156+ return InputCursor(r.com.Styles, r.input.Cursor())
157+}
158+
159+// Draw implements [Dialog].
160+func (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.VisibleItems())
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].
198+func (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].
207+func (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+
222+func (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.
267+func (r *ReasoningItem) Filter() string {
268+ return r.title
269+}
270+
271+// ID returns the unique identifier for the reasoning effort.
272+func (r *ReasoningItem) ID() string {
273+ return r.effort
274+}
275+
276+// SetFocused sets the focus state of the reasoning item.
277+func (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.
285+func (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.
291+func (r *ReasoningItem) Render(width int) string {
292+ info := ""
293+ if r.isCurrent {
294+ info = "current"
295+ }
296+ return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
297+}