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