@@ -0,0 +1,268 @@
1+package reasoning
2+
3+import (
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+
17+const (
18+ ReasoningDialogID dialogs.DialogID = "reasoning"
19+
20+ defaultWidth int = 50
21+)
22+
23+type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
24+
25+type EffortOption struct {
26+ Title string
27+ Effort string
28+}
29+
30+type ReasoningDialog interface {
31+ dialogs.DialogModel
32+}
33+
34+type 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+
44+type ReasoningEffortSelectedMsg struct {
45+ Effort string
46+}
47+
48+type ReasoningDialogKeyMap struct {
49+ Next key.Binding
50+ Previous key.Binding
51+ Select key.Binding
52+ Close key.Binding
53+}
54+
55+func 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+
76+func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
77+ return []key.Binding{k.Select, k.Close}
78+}
79+
80+func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
81+ return [][]key.Binding{
82+ {k.Next, k.Previous},
83+ {k.Select, k.Close},
84+ }
85+}
86+
87+func 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+
117+func (r *reasoningDialogCmp) Init() tea.Cmd {
118+ return r.populateEffortOptions()
119+}
120+
121+func (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+
175+func (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+
208+func (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+
223+func (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+
234+func (r *reasoningDialogCmp) listWidth() int {
235+ return r.width - 2 // 4 for padding
236+}
237+
238+func (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+
243+func (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+
251+func (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+
259+func (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+
266+func (r *reasoningDialogCmp) ID() dialogs.DialogID {
267+ return ReasoningDialogID
268+}