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