reasoning.go

  1package reasoning
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/help"
  5	"github.com/charmbracelet/bubbles/v2/key"
  6	tea "github.com/charmbracelet/bubbletea/v2"
  7	"github.com/charmbracelet/lipgloss/v2"
  8	"golang.org/x/text/cases"
  9	"golang.org/x/text/language"
 10
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/tui/components/core"
 13	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 14	"github.com/charmbracelet/crush/internal/tui/exp/list"
 15	"github.com/charmbracelet/crush/internal/tui/styles"
 16	"github.com/charmbracelet/crush/internal/tui/util"
 17)
 18
 19const (
 20	ReasoningDialogID dialogs.DialogID = "reasoning"
 21
 22	defaultWidth int = 50
 23)
 24
 25type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
 26
 27type EffortOption struct {
 28	Title  string
 29	Effort string
 30}
 31
 32type ReasoningDialog interface {
 33	dialogs.DialogModel
 34}
 35
 36type reasoningDialogCmp struct {
 37	width   int
 38	wWidth  int // Width of the terminal window
 39	wHeight int // Height of the terminal window
 40
 41	effortList listModel
 42	keyMap     ReasoningDialogKeyMap
 43	help       help.Model
 44}
 45
 46type ReasoningEffortSelectedMsg struct {
 47	Effort string
 48}
 49
 50type ReasoningDialogKeyMap struct {
 51	Next     key.Binding
 52	Previous key.Binding
 53	Select   key.Binding
 54	Close    key.Binding
 55}
 56
 57func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
 58	return ReasoningDialogKeyMap{
 59		Next: key.NewBinding(
 60			key.WithKeys("down", "j", "ctrl+n"),
 61			key.WithHelp("↓/j/ctrl+n", "next"),
 62		),
 63		Previous: key.NewBinding(
 64			key.WithKeys("up", "k", "ctrl+p"),
 65			key.WithHelp("↑/k/ctrl+p", "previous"),
 66		),
 67		Select: key.NewBinding(
 68			key.WithKeys("enter"),
 69			key.WithHelp("enter", "select"),
 70		),
 71		Close: key.NewBinding(
 72			key.WithKeys("esc", "ctrl+c"),
 73			key.WithHelp("esc/ctrl+c", "close"),
 74		),
 75	}
 76}
 77
 78func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
 79	return []key.Binding{k.Select, k.Close}
 80}
 81
 82func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
 83	return [][]key.Binding{
 84		{k.Next, k.Previous},
 85		{k.Select, k.Close},
 86	}
 87}
 88
 89func NewReasoningDialog() ReasoningDialog {
 90	keyMap := DefaultReasoningDialogKeyMap()
 91	listKeyMap := list.DefaultKeyMap()
 92	listKeyMap.Down.SetEnabled(false)
 93	listKeyMap.Up.SetEnabled(false)
 94	listKeyMap.DownOneItem = keyMap.Next
 95	listKeyMap.UpOneItem = keyMap.Previous
 96
 97	t := styles.CurrentTheme()
 98	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 99	effortList := list.NewFilterableList(
100		[]list.CompletionItem[EffortOption]{},
101		list.WithFilterInputStyle(inputStyle),
102		list.WithFilterListOptions(
103			list.WithKeyMap(listKeyMap),
104			list.WithWrapNavigation(),
105			list.WithResizeByList(),
106		),
107	)
108	help := help.New()
109	help.Styles = t.S().Help
110
111	return &reasoningDialogCmp{
112		effortList: effortList,
113		width:      defaultWidth,
114		keyMap:     keyMap,
115		help:       help,
116	}
117}
118
119func (r *reasoningDialogCmp) Init() tea.Cmd {
120	return r.populateEffortOptions()
121}
122
123func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
124	cfg := config.Get()
125	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
126		selectedModel := cfg.Models[agentCfg.Model]
127		model := cfg.GetModelByType(agentCfg.Model)
128
129		// Get current reasoning effort
130		currentEffort := selectedModel.ReasoningEffort
131		if currentEffort == "" && model != nil {
132			currentEffort = model.DefaultReasoningEffort
133		}
134
135		efforts := []EffortOption{}
136		caser := cases.Title(language.Und)
137		for _, level := range model.ReasoningLevels {
138			efforts = append(efforts, EffortOption{
139				Title:  caser.String(level),
140				Effort: level,
141			})
142		}
143
144		effortItems := []list.CompletionItem[EffortOption]{}
145		selectedID := ""
146		for _, effort := range efforts {
147			opts := []list.CompletionItemOption{
148				list.WithCompletionID(effort.Effort),
149			}
150			if effort.Effort == currentEffort {
151				opts = append(opts, list.WithCompletionShortcut("current"))
152				selectedID = effort.Effort
153			}
154			effortItems = append(effortItems, list.NewCompletionItem(
155				effort.Title,
156				effort,
157				opts...,
158			))
159		}
160
161		cmd := r.effortList.SetItems(effortItems)
162		// Set the current effort as the selected item
163		if currentEffort != "" && selectedID != "" {
164			return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
165		}
166		return cmd
167	}
168	return nil
169}
170
171func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
172	switch msg := msg.(type) {
173	case tea.WindowSizeMsg:
174		r.wWidth = msg.Width
175		r.wHeight = msg.Height
176		return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
177	case tea.KeyPressMsg:
178		switch {
179		case key.Matches(msg, r.keyMap.Select):
180			selectedItem := r.effortList.SelectedItem()
181			if selectedItem == nil {
182				return r, nil // No item selected, do nothing
183			}
184			effort := (*selectedItem).Value()
185			return r, tea.Sequence(
186				util.CmdHandler(dialogs.CloseDialogMsg{}),
187				func() tea.Msg {
188					return ReasoningEffortSelectedMsg{
189						Effort: effort.Effort,
190					}
191				},
192			)
193		case key.Matches(msg, r.keyMap.Close):
194			return r, util.CmdHandler(dialogs.CloseDialogMsg{})
195		default:
196			u, cmd := r.effortList.Update(msg)
197			r.effortList = u.(listModel)
198			return r, cmd
199		}
200	}
201	return r, nil
202}
203
204func (r *reasoningDialogCmp) View() string {
205	t := styles.CurrentTheme()
206	listView := r.effortList
207
208	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
209	content := lipgloss.JoinVertical(
210		lipgloss.Left,
211		header,
212		listView.View(),
213		"",
214		t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
215	)
216	return r.style().Render(content)
217}
218
219func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
220	if cursor, ok := r.effortList.(util.Cursor); ok {
221		cursor := cursor.Cursor()
222		if cursor != nil {
223			cursor = r.moveCursor(cursor)
224		}
225		return cursor
226	}
227	return nil
228}
229
230func (r *reasoningDialogCmp) listWidth() int {
231	return r.width - 2 // 4 for padding
232}
233
234func (r *reasoningDialogCmp) listHeight() int {
235	listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
236	return min(listHeight, r.wHeight/2)
237}
238
239func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
240	row, col := r.Position()
241	offset := row + 3
242	cursor.Y += offset
243	cursor.X = cursor.X + col + 2
244	return cursor
245}
246
247func (r *reasoningDialogCmp) style() lipgloss.Style {
248	t := styles.CurrentTheme()
249	return t.S().Base.
250		Width(r.width).
251		Border(lipgloss.RoundedBorder()).
252		BorderForeground(t.BorderFocus)
253}
254
255func (r *reasoningDialogCmp) Position() (int, int) {
256	row := r.wHeight/4 - 2 // just a bit above the center
257	col := r.wWidth / 2
258	col -= r.width / 2
259	return row, col
260}
261
262func (r *reasoningDialogCmp) ID() dialogs.DialogID {
263	return ReasoningDialogID
264}