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	cfg *config.Config
 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(cfg *config.Config) 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		cfg:        cfg,
117	}
118}
119
120func (r *reasoningDialogCmp) Init() tea.Cmd {
121	return r.populateEffortOptions()
122}
123
124func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
125	if agentCfg, ok := r.cfg.Agents["coder"]; ok {
126		selectedModel := r.cfg.Models[agentCfg.Model]
127		model := r.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			{
137				Title:  "Low",
138				Effort: "low",
139			},
140			{
141				Title:  "Medium",
142				Effort: "medium",
143			},
144			{
145				Title:  "High",
146				Effort: "high",
147			},
148		}
149
150		effortItems := []list.CompletionItem[EffortOption]{}
151		selectedID := ""
152		for _, effort := range efforts {
153			opts := []list.CompletionItemOption{
154				list.WithCompletionID(effort.Effort),
155			}
156			if effort.Effort == currentEffort {
157				opts = append(opts, list.WithCompletionShortcut("current"))
158				selectedID = effort.Effort
159			}
160			effortItems = append(effortItems, list.NewCompletionItem(
161				effort.Title,
162				effort,
163				opts...,
164			))
165		}
166
167		cmd := r.effortList.SetItems(effortItems)
168		// Set the current effort as the selected item
169		if currentEffort != "" && selectedID != "" {
170			return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
171		}
172		return cmd
173	}
174	return nil
175}
176
177func (r *reasoningDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178	switch msg := msg.(type) {
179	case tea.WindowSizeMsg:
180		r.wWidth = msg.Width
181		r.wHeight = msg.Height
182		return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
183	case tea.KeyPressMsg:
184		switch {
185		case key.Matches(msg, r.keyMap.Select):
186			selectedItem := r.effortList.SelectedItem()
187			if selectedItem == nil {
188				return r, nil // No item selected, do nothing
189			}
190			effort := (*selectedItem).Value()
191			return r, tea.Sequence(
192				util.CmdHandler(dialogs.CloseDialogMsg{}),
193				func() tea.Msg {
194					return ReasoningEffortSelectedMsg{
195						Effort: effort.Effort,
196					}
197				},
198			)
199		case key.Matches(msg, r.keyMap.Close):
200			return r, util.CmdHandler(dialogs.CloseDialogMsg{})
201		default:
202			u, cmd := r.effortList.Update(msg)
203			r.effortList = u.(listModel)
204			return r, cmd
205		}
206	}
207	return r, nil
208}
209
210func (r *reasoningDialogCmp) View() string {
211	t := styles.CurrentTheme()
212	listView := r.effortList
213
214	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
215	content := lipgloss.JoinVertical(
216		lipgloss.Left,
217		header,
218		listView.View(),
219		"",
220		t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
221	)
222	return r.style().Render(content)
223}
224
225func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
226	if cursor, ok := r.effortList.(util.Cursor); ok {
227		cursor := cursor.Cursor()
228		if cursor != nil {
229			cursor = r.moveCursor(cursor)
230		}
231		return cursor
232	}
233	return nil
234}
235
236func (r *reasoningDialogCmp) listWidth() int {
237	return r.width - 2 // 4 for padding
238}
239
240func (r *reasoningDialogCmp) listHeight() int {
241	listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
242	return min(listHeight, r.wHeight/2)
243}
244
245func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
246	row, col := r.Position()
247	offset := row + 3
248	cursor.Y += offset
249	cursor.X = cursor.X + col + 2
250	return cursor
251}
252
253func (r *reasoningDialogCmp) style() lipgloss.Style {
254	t := styles.CurrentTheme()
255	return t.S().Base.
256		Width(r.width).
257		Border(lipgloss.RoundedBorder()).
258		BorderForeground(t.BorderFocus)
259}
260
261func (r *reasoningDialogCmp) Position() (int, int) {
262	row := r.wHeight/4 - 2 // just a bit above the center
263	col := r.wWidth / 2
264	col -= r.width / 2
265	return row, col
266}
267
268func (r *reasoningDialogCmp) ID() dialogs.DialogID {
269	return ReasoningDialogID
270}