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	*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}