Detailed changes
@@ -54,6 +54,10 @@ type (
ActionSummarize struct {
SessionID string
}
+ // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
+ ActionSelectReasoningEffort struct {
+ Effort string
+ }
ActionPermissionResponse struct {
Permission permission.PermissionRequest
Action PermissionAction
@@ -10,6 +10,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -400,7 +401,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
selectedModel := cfg.Models[agentCfg.Model]
// Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic {
+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
status := "Enable"
if selectedModel.Think {
status = "Disable"
@@ -411,7 +412,7 @@ func (c *Commands) defaultCommands() []*CommandItem {
// OpenAI models: reasoning effort dialog
if len(model.ReasoningLevels) > 0 {
commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
- // TODO: Pass in the reasoning effort dialog id
+ DialogID: ReasoningID,
}))
}
}
@@ -0,0 +1,297 @@
+package dialog
+
+import (
+ "errors"
+
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/list"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/sahilm/fuzzy"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+const (
+ // ReasoningID is the identifier for the reasoning effort dialog.
+ ReasoningID = "reasoning"
+ reasoningDialogMaxWidth = 80
+ reasoningDialogMaxHeight = 12
+)
+
+// Reasoning represents a dialog for selecting reasoning effort.
+type Reasoning struct {
+ com *common.Common
+ help help.Model
+ list *list.FilterableList
+ input textinput.Model
+
+ keyMap struct {
+ Select key.Binding
+ Next key.Binding
+ Previous key.Binding
+ UpDown key.Binding
+ Close key.Binding
+ }
+}
+
+// ReasoningItem represents a reasoning effort list item.
+type ReasoningItem struct {
+ effort string
+ title string
+ isCurrent bool
+ t *styles.Styles
+ m fuzzy.Match
+ cache map[int]string
+ focused bool
+}
+
+var (
+ _ Dialog = (*Reasoning)(nil)
+ _ ListItem = (*ReasoningItem)(nil)
+)
+
+// NewReasoning creates a new reasoning effort dialog.
+func NewReasoning(com *common.Common) (*Reasoning, error) {
+ r := &Reasoning{com: com}
+
+ help := help.New()
+ help.Styles = com.Styles.DialogHelpStyles()
+ r.help = help
+
+ r.list = list.NewFilterableList()
+ r.list.Focus()
+
+ r.input = textinput.New()
+ r.input.SetVirtualCursor(false)
+ r.input.Placeholder = "Type to filter"
+ r.input.SetStyles(com.Styles.TextInput)
+ r.input.Focus()
+
+ r.keyMap.Select = key.NewBinding(
+ key.WithKeys("enter", "ctrl+y"),
+ key.WithHelp("enter", "confirm"),
+ )
+ r.keyMap.Next = key.NewBinding(
+ key.WithKeys("down", "ctrl+n"),
+ key.WithHelp("↓", "next item"),
+ )
+ r.keyMap.Previous = key.NewBinding(
+ key.WithKeys("up", "ctrl+p"),
+ key.WithHelp("↑", "previous item"),
+ )
+ r.keyMap.UpDown = key.NewBinding(
+ key.WithKeys("up", "down"),
+ key.WithHelp("↑/↓", "choose"),
+ )
+ r.keyMap.Close = CloseKey
+
+ if err := r.setReasoningItems(); err != nil {
+ return nil, err
+ }
+
+ return r, nil
+}
+
+// ID implements Dialog.
+func (r *Reasoning) ID() string {
+ return ReasoningID
+}
+
+// HandleMsg implements [Dialog].
+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
+ switch msg := msg.(type) {
+ case tea.KeyPressMsg:
+ switch {
+ case key.Matches(msg, r.keyMap.Close):
+ return ActionClose{}
+ case key.Matches(msg, r.keyMap.Previous):
+ r.list.Focus()
+ if r.list.IsSelectedFirst() {
+ r.list.SelectLast()
+ r.list.ScrollToBottom()
+ break
+ }
+ r.list.SelectPrev()
+ r.list.ScrollToSelected()
+ case key.Matches(msg, r.keyMap.Next):
+ r.list.Focus()
+ if r.list.IsSelectedLast() {
+ r.list.SelectFirst()
+ r.list.ScrollToTop()
+ break
+ }
+ r.list.SelectNext()
+ r.list.ScrollToSelected()
+ case key.Matches(msg, r.keyMap.Select):
+ selectedItem := r.list.SelectedItem()
+ if selectedItem == nil {
+ break
+ }
+ reasoningItem, ok := selectedItem.(*ReasoningItem)
+ if !ok {
+ break
+ }
+ return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
+ default:
+ var cmd tea.Cmd
+ r.input, cmd = r.input.Update(msg)
+ value := r.input.Value()
+ r.list.SetFilter(value)
+ r.list.ScrollToTop()
+ r.list.SetSelected(0)
+ return ActionCmd{cmd}
+ }
+ }
+ return nil
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (r *Reasoning) Cursor() *tea.Cursor {
+ return InputCursor(r.com.Styles, r.input.Cursor())
+}
+
+// Draw implements [Dialog].
+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := r.com.Styles
+ width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
+ height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
+ t.Dialog.HelpView.GetVerticalFrameSize() +
+ t.Dialog.View.GetVerticalFrameSize()
+
+ r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
+ r.list.SetSize(innerWidth, height-heightOffset)
+ r.help.SetWidth(innerWidth)
+
+ rc := NewRenderContext(t, width)
+ rc.Title = "Select Reasoning Effort"
+ inputView := t.Dialog.InputPrompt.Render(r.input.View())
+ rc.AddPart(inputView)
+
+ visibleCount := len(r.list.VisibleItems())
+ if r.list.Height() >= visibleCount {
+ r.list.ScrollToTop()
+ } else {
+ r.list.ScrollToSelected()
+ }
+
+ listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
+ rc.AddPart(listView)
+ rc.Help = r.help.View(r)
+
+ view := rc.Render()
+
+ cur := r.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
+}
+
+// ShortHelp implements [help.KeyMap].
+func (r *Reasoning) ShortHelp() []key.Binding {
+ return []key.Binding{
+ r.keyMap.UpDown,
+ r.keyMap.Select,
+ r.keyMap.Close,
+ }
+}
+
+// FullHelp implements [help.KeyMap].
+func (r *Reasoning) FullHelp() [][]key.Binding {
+ m := [][]key.Binding{}
+ slice := []key.Binding{
+ r.keyMap.Select,
+ r.keyMap.Next,
+ r.keyMap.Previous,
+ r.keyMap.Close,
+ }
+ for i := 0; i < len(slice); i += 4 {
+ end := min(i+4, len(slice))
+ m = append(m, slice[i:end])
+ }
+ return m
+}
+
+func (r *Reasoning) setReasoningItems() error {
+ cfg := r.com.Config()
+ agentCfg, ok := cfg.Agents[config.AgentCoder]
+ if !ok {
+ return errors.New("agent configuration not found")
+ }
+
+ selectedModel := cfg.Models[agentCfg.Model]
+ model := cfg.GetModelByType(agentCfg.Model)
+ if model == nil {
+ return errors.New("model configuration not found")
+ }
+
+ if len(model.ReasoningLevels) == 0 {
+ return errors.New("no reasoning levels available")
+ }
+
+ currentEffort := selectedModel.ReasoningEffort
+ if currentEffort == "" {
+ currentEffort = model.DefaultReasoningEffort
+ }
+
+ caser := cases.Title(language.English)
+ items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
+ selectedIndex := 0
+ for i, effort := range model.ReasoningLevels {
+ item := &ReasoningItem{
+ effort: effort,
+ title: caser.String(effort),
+ isCurrent: effort == currentEffort,
+ t: r.com.Styles,
+ }
+ items = append(items, item)
+ if effort == currentEffort {
+ selectedIndex = i
+ }
+ }
+
+ r.list.SetItems(items...)
+ r.list.SetSelected(selectedIndex)
+ r.list.ScrollToSelected()
+ return nil
+}
+
+// Filter returns the filter value for the reasoning item.
+func (r *ReasoningItem) Filter() string {
+ return r.title
+}
+
+// ID returns the unique identifier for the reasoning effort.
+func (r *ReasoningItem) ID() string {
+ return r.effort
+}
+
+// SetFocused sets the focus state of the reasoning item.
+func (r *ReasoningItem) SetFocused(focused bool) {
+ if r.focused != focused {
+ r.cache = nil
+ }
+ r.focused = focused
+}
+
+// SetMatch sets the fuzzy match for the reasoning item.
+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
+ r.cache = nil
+ r.m = m
+}
+
+// Render returns the string representation of the reasoning item.
+func (r *ReasoningItem) Render(width int) string {
+ info := ""
+ if r.isCurrent {
+ info = "current"
+ }
+ return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
+}
@@ -925,6 +925,36 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.ActionToggleCompactMode:
cmds = append(cmds, m.toggleCompactMode())
m.dialog.CloseDialog(dialog.CommandsID)
+ case dialog.ActionToggleThinking:
+ if m.com.App.AgentCoordinator.IsBusy() {
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ break
+ }
+
+ cmds = append(cmds, func() tea.Msg {
+ cfg := m.com.Config()
+ if cfg == nil {
+ return uiutil.ReportError(errors.New("configuration not found"))()
+ }
+
+ agentCfg, ok := cfg.Agents[config.AgentCoder]
+ if !ok {
+ return uiutil.ReportError(errors.New("agent configuration not found"))()
+ }
+
+ currentModel := cfg.Models[agentCfg.Model]
+ currentModel.Think = !currentModel.Think
+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+ return uiutil.ReportError(err)()
+ }
+ m.com.App.UpdateAgentModel(context.TODO())
+ status := "disabled"
+ if currentModel.Think {
+ status = "enabled"
+ }
+ return uiutil.NewInfoMsg("Thinking mode " + status)
+ })
+ m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionQuit:
cmds = append(cmds, tea.Quit)
case dialog.ActionInitializeProject:
@@ -969,15 +999,47 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
cmds = append(cmds, uiutil.ReportError(err))
}
- // XXX: Should this be in a separate goroutine?
- go m.com.App.UpdateAgentModel(context.TODO())
+ cmds = append(cmds, func() tea.Msg {
+ m.com.App.UpdateAgentModel(context.TODO())
+
+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+
+ return uiutil.NewInfoMsg(modelMsg)
+ })
- modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
- cmds = append(cmds, uiutil.ReportInfo(modelMsg))
m.dialog.CloseDialog(dialog.APIKeyInputID)
m.dialog.CloseDialog(dialog.OAuthID)
m.dialog.CloseDialog(dialog.ModelsID)
- // TODO CHANGE
+ case dialog.ActionSelectReasoningEffort:
+ if m.com.App.AgentCoordinator.IsBusy() {
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ break
+ }
+
+ cfg := m.com.Config()
+ if cfg == nil {
+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+ break
+ }
+
+ agentCfg, ok := cfg.Agents[config.AgentCoder]
+ if !ok {
+ cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+ break
+ }
+
+ currentModel := cfg.Models[agentCfg.Model]
+ currentModel.ReasoningEffort = msg.Effort
+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+ cmds = append(cmds, uiutil.ReportError(err))
+ break
+ }
+
+ cmds = append(cmds, func() tea.Msg {
+ m.com.App.UpdateAgentModel(context.TODO())
+ return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+ })
+ m.dialog.CloseDialog(dialog.ReasoningID)
case dialog.ActionPermissionResponse:
m.dialog.CloseDialog(dialog.PermissionsID)
switch msg.Action {
@@ -2248,6 +2310,10 @@ func (m *UI) openDialog(id string) tea.Cmd {
if cmd := m.openCommandsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
+ case dialog.ReasoningID:
+ if cmd := m.openReasoningDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case dialog.QuitID:
if cmd := m.openQuitDialog(); cmd != nil {
cmds = append(cmds, cmd)
@@ -2313,6 +2379,22 @@ func (m *UI) openCommandsDialog() tea.Cmd {
return nil
}
+// openReasoningDialog opens the reasoning effort dialog.
+func (m *UI) openReasoningDialog() tea.Cmd {
+ if m.dialog.ContainsDialog(dialog.ReasoningID) {
+ m.dialog.BringToFront(dialog.ReasoningID)
+ return nil
+ }
+
+ reasoningDialog, err := dialog.NewReasoning(m.com)
+ if err != nil {
+ return uiutil.ReportError(err)
+ }
+
+ m.dialog.OpenDialog(reasoningDialog)
+ return nil
+}
+
// openSessionsDialog opens the sessions dialog. If the dialog is already open,
// it brings it to the front. Otherwise, it will list all the sessions and open
// the dialog.
@@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
func ReportError(err error) tea.Cmd {
slog.Error("Error reported", "error", err)
- return CmdHandler(InfoMsg{
- Type: InfoTypeError,
- Msg: err.Error(),
- })
+ return CmdHandler(NewErrorMsg(err))
}
type InfoType int
@@ -42,18 +39,33 @@ const (
InfoTypeUpdate
)
-func ReportInfo(info string) tea.Cmd {
- return CmdHandler(InfoMsg{
+func NewInfoMsg(info string) InfoMsg {
+ return InfoMsg{
Type: InfoTypeInfo,
Msg: info,
- })
+ }
}
-func ReportWarn(warn string) tea.Cmd {
- return CmdHandler(InfoMsg{
+func NewWarnMsg(warn string) InfoMsg {
+ return InfoMsg{
Type: InfoTypeWarn,
Msg: warn,
- })
+ }
+}
+
+func NewErrorMsg(err error) InfoMsg {
+ return InfoMsg{
+ Type: InfoTypeError,
+ Msg: err.Error(),
+ }
+}
+
+func ReportInfo(info string) tea.Cmd {
+ return CmdHandler(NewInfoMsg(info))
+}
+
+func ReportWarn(warn string) tea.Cmd {
+ return CmdHandler(NewWarnMsg(warn))
}
type (