reasoning.go 6.5 KB

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