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}