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}