reasoning.go

  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}