Detailed changes
@@ -0,0 +1,48 @@
+package dialog
+
+import (
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/session"
+)
+
+// ActionClose is a message to close the current dialog.
+type ActionClose struct{}
+
+// ActionQuit is a message to quit the application.
+type ActionQuit = tea.QuitMsg
+
+// ActionOpenDialog is a message to open a dialog.
+type ActionOpenDialog struct {
+ DialogID string
+}
+
+// ActionSelectSession is a message indicating a session has been selected.
+type ActionSelectSession struct {
+ Session session.Session
+}
+
+// ActionSelectModel is a message indicating a model has been selected.
+type ActionSelectModel struct {
+ Model config.SelectedModel
+ ModelType config.SelectedModelType
+}
+
+// Messages for commands
+type (
+ ActionNewSession struct{}
+ ActionToggleHelp struct{}
+ ActionToggleCompactMode struct{}
+ ActionToggleThinking struct{}
+ ActionExternalEditor struct{}
+ ActionToggleYoloMode struct{}
+ ActionSummarize struct {
+ SessionID string
+ }
+)
+
+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
+// Bubble Tea program loop.
+type ActionCmd struct {
+ Cmd tea.Cmd
+}
@@ -21,6 +21,8 @@ import (
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/uicmd"
"github.com/charmbracelet/crush/internal/uiutil"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
)
// CommandsID is the identifier for the commands dialog.
@@ -43,10 +45,11 @@ type Commands struct {
userCmds []uicmd.Command
mcpPrompts *csync.Slice[uicmd.Command]
- help help.Model
- input textinput.Model
- list *list.FilterableList
- width, height int
+ help help.Model
+ input textinput.Model
+ list *list.FilterableList
+
+ width int
}
var _ Dialog = (*Commands)(nil)
@@ -114,33 +117,18 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
return c, nil
}
-// SetSize sets the size of the dialog.
-func (c *Commands) SetSize(width, height int) {
- t := c.com.Styles
- c.width = width
- c.height = height
- innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
- heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
- t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
- t.Dialog.HelpView.GetVerticalFrameSize() +
- t.Dialog.View.GetVerticalFrameSize()
- c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
- c.list.SetSize(innerWidth, height-heightOffset)
- c.help.SetWidth(width)
-}
-
// ID implements Dialog.
func (c *Commands) ID() string {
return CommandsID
}
-// Update implements Dialog.
-func (c *Commands) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (c *Commands) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keyMap.Close):
- return CloseMsg{}
+ return ActionClose{}
case key.Matches(msg, c.keyMap.Previous):
c.list.Focus()
if c.list.IsSelectedFirst() {
@@ -181,9 +169,7 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg {
c.list.SetFilter(value)
c.list.ScrollToTop()
c.list.SetSelected(0)
- if cmd != nil {
- return cmd()
- }
+ return ActionCmd{cmd}
}
}
return nil
@@ -231,16 +217,35 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
return strings.Join(parts, " ")
}
-// View implements [Dialog].
-func (c *Commands) View() string {
+// Draw implements [Dialog].
+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := c.com.Styles
+ width := max(0, min(100, area.Dx()))
+ height := max(0, min(30, area.Dy()))
+ c.width = width
+ // TODO: Why do we need this 2?
+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+ t.Dialog.HelpView.GetVerticalFrameSize() +
+ // TODO: Why do we need this 2?
+ t.Dialog.View.GetVerticalFrameSize() + 2
+ c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+ c.list.SetSize(innerWidth, height-heightOffset)
+ c.help.SetWidth(innerWidth)
+
radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
titleStyle := t.Dialog.Title
- dialogStyle := t.Dialog.View.Width(c.width)
+ dialogStyle := t.Dialog.View.Width(width)
headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
- header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
- return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
- c.input.View(), c.list.Render(), c.help.View(c))
+ helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
+ header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
+ view := HeaderInputListHelpView(t, width, c.list.Height(), header,
+ c.input.View(), c.list.Render(), helpView)
+
+ cur := c.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
}
// ShortHelp implements [help.KeyMap].
@@ -318,7 +323,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Description: "start a new session",
Shortcut: "ctrl+n",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(NewSessionsMsg{})
+ return uiutil.CmdHandler(ActionNewSession{})
},
},
{
@@ -327,7 +332,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Description: "Switch to a different session",
Shortcut: "ctrl+s",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
+ return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
},
},
{
@@ -337,7 +342,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
// FIXME: The shortcut might get updated if enhanced keyboard is supported.
Shortcut: "ctrl+l",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
+ return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
},
},
}
@@ -349,7 +354,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Title: "Summarize Session",
Description: "Summarize the current session and create a new one with the summary",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(CompactMsg{
+ return uiutil.CmdHandler(ActionSummarize{
SessionID: c.sessionID,
})
},
@@ -375,7 +380,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Title: status + " Thinking Mode",
Description: "Toggle model thinking for reasoning-capable models",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(ToggleThinkingMsg{})
+ return uiutil.CmdHandler(ActionToggleThinking{})
},
})
}
@@ -387,7 +392,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Title: "Select Reasoning Effort",
Description: "Choose reasoning effort level (low/medium/high)",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(OpenReasoningDialogMsg{})
+ return uiutil.CmdHandler(ActionOpenDialog{
+ // TODO: Pass reasoning dialog id
+ })
},
})
}
@@ -401,7 +408,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Title: "Toggle Sidebar",
Description: "Toggle between compact and normal layout",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(ToggleCompactModeMsg{})
+ return uiutil.CmdHandler(ActionToggleCompactMode{})
},
})
}
@@ -416,7 +423,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Shortcut: "ctrl+f",
Description: "Open file picker",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(OpenFilePickerMsg{})
+ return uiutil.CmdHandler(ActionOpenDialog{
+ // TODO: Pass file picker dialog id
+ })
},
})
}
@@ -431,7 +440,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Shortcut: "ctrl+o",
Description: "Open external editor to compose message",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(OpenExternalEditorMsg{})
+ return uiutil.CmdHandler(ActionExternalEditor{})
},
})
}
@@ -442,7 +451,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Title: "Toggle Yolo Mode",
Description: "Toggle yolo mode",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(ToggleYoloModeMsg{})
+ return uiutil.CmdHandler(ActionToggleYoloMode{})
},
},
{
@@ -451,7 +460,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
Shortcut: "ctrl+g",
Description: "Toggle help",
Handler: func(cmd uicmd.Command) tea.Cmd {
- return uiutil.CmdHandler(ToggleHelpMsg{})
+ return uiutil.CmdHandler(ActionToggleHelp{})
},
},
{
@@ -14,11 +14,19 @@ var CloseKey = key.NewBinding(
key.WithHelp("esc", "exit"),
)
+// Action represents an action taken in a dialog after handling a message.
+type Action interface{}
+
// Dialog is a component that can be displayed on top of the UI.
type Dialog interface {
+ // ID returns the unique identifier of the dialog.
ID() string
- Update(msg tea.Msg) tea.Msg
- View() string
+ // HandleMsg processes a message and returns an action. An [Action] can be
+ // anything and the caller is responsible for handling it appropriately.
+ HandleMsg(msg tea.Msg) Action
+ // Draw draws the dialog onto the provided screen within the specified area
+ // and returns the desired cursor position on the screen.
+ Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
}
// Overlay manages multiple dialogs as an overlay.
@@ -113,33 +121,34 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
return nil
}
- return dialog.Update(msg)
+ return dialog.HandleMsg(msg)
}
-// CenterPosition calculates the centered position for the dialog.
-func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
- dialog := d.Dialog(dialogID)
- if dialog == nil {
- return uv.Rectangle{}
+// DrawCenterCursor draws the given string view centered in the screen area and
+// adjusts the cursor position accordingly.
+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
+ width, height := lipgloss.Size(view)
+ center := common.CenterRect(area, width, height)
+ if cur != nil {
+ cur.X += center.Min.X
+ cur.Y += center.Min.Y
}
- return d.centerPositionView(area, dialog.View())
+
+ uv.NewStyledString(view).Draw(scr, center)
}
-func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
- viewWidth := lipgloss.Width(view)
- viewHeight := lipgloss.Height(view)
- return common.CenterRect(area, viewWidth, viewHeight)
+// DrawCenter draws the given string view centered in the screen area.
+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
+ DrawCenterCursor(scr, area, view, nil)
}
// Draw renders the overlay and its dialogs.
-func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ var cur *tea.Cursor
for _, dialog := range d.dialogs {
- view := dialog.View()
- center := d.centerPositionView(area, view)
- if area.Overlaps(center) {
- uv.NewStyledString(view).Draw(scr, center)
- }
+ cur = dialog.Draw(scr, area)
}
+ return cur
}
// removeDialog removes a dialog from the stack.
@@ -1,44 +0,0 @@
-package dialog
-
-import (
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/session"
-)
-
-// CloseMsg is a message to close the current dialog.
-type CloseMsg struct{}
-
-// QuitMsg is a message to quit the application.
-type QuitMsg = tea.QuitMsg
-
-// OpenDialogMsg is a message to open a dialog.
-type OpenDialogMsg struct {
- DialogID string
-}
-
-// SessionSelectedMsg is a message indicating a session has been selected.
-type SessionSelectedMsg struct {
- Session session.Session
-}
-
-// ModelSelectedMsg is a message indicating a model has been selected.
-type ModelSelectedMsg struct {
- Model config.SelectedModel
- ModelType config.SelectedModelType
-}
-
-// Messages for commands
-type (
- NewSessionsMsg struct{}
- OpenFilePickerMsg struct{}
- ToggleHelpMsg struct{}
- ToggleCompactModeMsg struct{}
- ToggleThinkingMsg struct{}
- OpenReasoningDialogMsg struct{}
- OpenExternalEditorMsg struct{}
- ToggleYoloModeMsg struct{}
- CompactMsg struct {
- SessionID string
- }
-)
@@ -15,6 +15,8 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/uiutil"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
)
// ModelType represents the type of model to select.
@@ -76,8 +78,6 @@ type Models struct {
modelType ModelType
providers []catwalk.Provider
- width, height int
-
keyMap struct {
Tab key.Binding
UpDown key.Binding
@@ -147,33 +147,18 @@ func NewModels(com *common.Common) (*Models, error) {
return m, nil
}
-// SetSize sets the size of the dialog.
-func (m *Models) SetSize(width, height int) {
- t := m.com.Styles
- m.width = width
- m.height = height
- innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
- heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
- t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
- t.Dialog.HelpView.GetVerticalFrameSize() +
- t.Dialog.View.GetVerticalFrameSize()
- m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
- m.list.SetSize(innerWidth, height-heightOffset)
- m.help.SetWidth(width)
-}
-
// ID implements Dialog.
func (m *Models) ID() string {
return ModelsID
}
-// Update implements Dialog.
-func (m *Models) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (m *Models) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.keyMap.Close):
- return CloseMsg{}
+ return ActionClose{}
case key.Matches(msg, m.keyMap.Previous):
m.list.Focus()
if m.list.IsSelectedFirst() {
@@ -203,7 +188,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
break
}
- return ModelSelectedMsg{
+ return ActionSelectModel{
Model: modelItem.SelectedModel(),
ModelType: modelItem.SelectedModelType(),
}
@@ -222,9 +207,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
value := m.input.Value()
m.list.SetFilter(value)
m.list.ScrollToSelected()
- if cmd != nil {
- return cmd()
- }
+ return ActionCmd{cmd}
}
}
return nil
@@ -255,9 +238,22 @@ func (m *Models) modelTypeRadioView() string {
smallRadio, textStyle.Render(ModelTypeSmall.String()))
}
-// View implements Dialog.
-func (m *Models) View() string {
+// Draw implements [Dialog].
+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
t := m.com.Styles
+ width := max(0, min(60, area.Dx()))
+ height := max(0, min(30, area.Dy()))
+ // TODO: Why do we need this 2?
+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+ t.Dialog.HelpView.GetVerticalFrameSize() +
+ // TODO: Why do we need this 2?
+ t.Dialog.View.GetVerticalFrameSize() + 2
+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+ m.list.SetSize(innerWidth, height-heightOffset)
+ m.help.SetWidth(innerWidth)
+
titleStyle := t.Dialog.Title
dialogStyle := t.Dialog.View
@@ -266,10 +262,15 @@ func (m *Models) View() string {
headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
dialogStyle.GetHorizontalFrameSize()
- header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios
+ header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
+
+ helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
+ view := HeaderInputListHelpView(t, width, m.list.Height(), header,
+ m.input.View(), m.list.Render(), helpView)
- return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
- m.input.View(), m.list.Render(), m.help.View(m))
+ cur := m.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
}
// ShortHelp returns the short help view.
@@ -5,6 +5,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/common"
+ uv "github.com/charmbracelet/ultraviolet"
)
// QuitID is the identifier for the quit dialog.
@@ -25,6 +26,8 @@ type Quit struct {
}
}
+var _ Dialog = (*Quit)(nil)
+
// NewQuit creates a new quit confirmation dialog.
func NewQuit(com *common.Common) *Quit {
q := &Quit{
@@ -64,34 +67,34 @@ func (*Quit) ID() string {
return QuitID
}
-// Update implements [Model].
-func (q *Quit) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements [Model].
+func (q *Quit) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, q.keyMap.Quit):
- return QuitMsg{}
+ return ActionQuit{}
case key.Matches(msg, q.keyMap.Close):
- return CloseMsg{}
+ return ActionClose{}
case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
q.selectedNo = !q.selectedNo
case key.Matches(msg, q.keyMap.EnterSpace):
if !q.selectedNo {
- return QuitMsg{}
+ return ActionQuit{}
}
- return CloseMsg{}
+ return ActionClose{}
case key.Matches(msg, q.keyMap.Yes):
- return QuitMsg{}
+ return ActionQuit{}
case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
- return CloseMsg{}
+ return ActionClose{}
}
}
return nil
}
-// View implements [Dialog].
-func (q *Quit) View() string {
+// Draw implements [Dialog].
+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
const question = "Are you sure you want to quit?"
baseStyle := q.com.Styles.Base
buttonOpts := []common.ButtonOpts{
@@ -108,7 +111,9 @@ func (q *Quit) View() string {
),
)
- return q.com.Styles.BorderFocus.Render(content)
+ view := q.com.Styles.BorderFocus.Render(content)
+ DrawCenter(scr, area, view)
+ return nil
}
// ShortHelp implements [help.KeyMap].
@@ -9,6 +9,8 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/list"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
)
// SessionsID is the identifier for the session selector dialog.
@@ -16,7 +18,6 @@ const SessionsID = "session"
// Session is a session selector dialog.
type Session struct {
- width, height int
com *common.Common
help help.Model
list *list.FilterableList
@@ -56,6 +57,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
s.help = help
s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
s.list.Focus()
+ s.list.SetSelected(s.selectedSessionInx)
+ s.list.ScrollToSelected()
s.input = textinput.New()
s.input.SetVirtualCursor(false)
@@ -84,37 +87,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
return s, nil
}
-// SetSize sets the size of the dialog.
-func (s *Session) SetSize(width, height int) {
- t := s.com.Styles
- s.width = width
- s.height = height
- innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
- heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
- t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
- t.Dialog.HelpView.GetVerticalFrameSize() +
- t.Dialog.View.GetVerticalFrameSize()
- s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
- s.list.SetSize(innerWidth, height-heightOffset)
- s.help.SetWidth(width)
-
- // Now that we know the height we can select the selected session and scroll to it.
- s.list.SetSelected(s.selectedSessionInx)
- s.list.ScrollToSelected()
-}
-
// ID implements Dialog.
func (s *Session) ID() string {
return SessionsID
}
-// Update implements Dialog.
-func (s *Session) Update(msg tea.Msg) tea.Msg {
+// HandleMsg implements Dialog.
+func (s *Session) HandleMsg(msg tea.Msg) Action {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, s.keyMap.Close):
- return CloseMsg{}
+ return ActionClose{}
case key.Matches(msg, s.keyMap.Previous):
s.list.Focus()
if s.list.IsSelectedFirst() {
@@ -136,7 +120,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
case key.Matches(msg, s.keyMap.Select):
if item := s.list.SelectedItem(); item != nil {
sessionItem := item.(*SessionItem)
- return SessionSelectedMsg{sessionItem.Session}
+ return ActionSelectSession{sessionItem.Session}
}
default:
var cmd tea.Cmd
@@ -145,9 +129,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
s.list.SetFilter(value)
s.list.ScrollToTop()
s.list.SetSelected(0)
- if cmd != nil {
- return cmd()
- }
+ return ActionCmd{cmd}
}
}
return nil
@@ -158,16 +140,35 @@ func (s *Session) Cursor() *tea.Cursor {
return InputCursor(s.com.Styles, s.input.Cursor())
}
-// View implements [Dialog].
-func (s *Session) View() string {
+// Draw implements [Dialog].
+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := s.com.Styles
+ width := max(0, min(120, area.Dx()))
+ height := max(0, min(30, area.Dy()))
+ // TODO: Why do we need this 2?
+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
+ t.Dialog.HelpView.GetVerticalFrameSize() +
+ // TODO: Why do we need this 2?
+ t.Dialog.View.GetVerticalFrameSize() + 2
+ s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+ s.list.SetSize(innerWidth, height-heightOffset)
+ s.help.SetWidth(innerWidth)
+
titleStyle := s.com.Styles.Dialog.Title
- dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
+ dialogStyle := s.com.Styles.Dialog.View.Width(width)
header := common.DialogTitle(s.com.Styles, "Switch Session",
- max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
+ max(0, width-dialogStyle.GetHorizontalFrameSize()-
titleStyle.GetHorizontalFrameSize()))
- return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
- s.input.View(), s.list.Render(), s.help.View(s))
+ helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
+ view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
+ s.input.View(), s.list.Render(), helpView)
+
+ cur := s.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
}
// ShortHelp implements [help.KeyMap].
@@ -74,7 +74,7 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
}
var infoText string
- var infoLen int
+ var infoWidth int
lineWidth := width
if len(info) > 0 {
infoText = fmt.Sprintf(" %s ", info)
@@ -84,12 +84,12 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
infoText = t.Subtle.Render(infoText)
}
- infoLen = lipgloss.Width(infoText)
+ infoWidth = lipgloss.Width(infoText)
}
- title = ansi.Truncate(title, max(0, lineWidth), "")
- titleLen := lipgloss.Width(title)
- gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen))
+ title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
+ titleWidth := lipgloss.Width(title)
+ gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
content := title
if matches := len(m.MatchedIndexes); matches > 0 {
var lastPos int
@@ -404,6 +404,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.completionsOpen {
m.completions.SetFiles(msg.Files)
}
+ default:
+ if m.dialog.HasDialogs() {
+ if cmd := m.handleDialogMsg(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
}
// This logic gets triggered on any message type, but should it?
@@ -690,6 +696,93 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
return tea.Batch(cmds...)
}
+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
+ var cmds []tea.Cmd
+ action := m.dialog.Update(msg)
+ if action == nil {
+ return tea.Batch(cmds...)
+ }
+
+ switch msg := action.(type) {
+ // Generic dialog messages
+ case dialog.ActionClose:
+ m.dialog.CloseFrontDialog()
+ if m.focus == uiFocusEditor {
+ cmds = append(cmds, m.textarea.Focus())
+ }
+ case dialog.ActionCmd:
+ if msg.Cmd != nil {
+ cmds = append(cmds, msg.Cmd)
+ }
+
+ // Session dialog messages
+ case dialog.ActionSelectSession:
+ m.dialog.CloseDialog(dialog.SessionsID)
+ cmds = append(cmds, m.loadSession(msg.Session.ID))
+
+ // Open dialog message
+ case dialog.ActionOpenDialog:
+ m.dialog.CloseDialog(dialog.CommandsID)
+ if cmd := m.openDialog(msg.DialogID); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ // Command dialog messages
+ case dialog.ActionToggleYoloMode:
+ yolo := !m.com.App.Permissions.SkipRequests()
+ m.com.App.Permissions.SetSkipRequests(yolo)
+ m.setEditorPrompt(yolo)
+ m.dialog.CloseDialog(dialog.CommandsID)
+ case dialog.ActionNewSession:
+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+ break
+ }
+ m.newSession()
+ m.dialog.CloseDialog(dialog.CommandsID)
+ case dialog.ActionSummarize:
+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+ break
+ }
+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+ if err != nil {
+ cmds = append(cmds, uiutil.ReportError(err))
+ }
+ case dialog.ActionToggleHelp:
+ m.status.ToggleHelp()
+ m.dialog.CloseDialog(dialog.CommandsID)
+ case dialog.ActionQuit:
+ cmds = append(cmds, tea.Quit)
+ case dialog.ActionSelectModel:
+ if m.com.App.AgentCoordinator.IsBusy() {
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ break
+ }
+
+ // TODO: Validate model API and authentication here?
+
+ cfg := m.com.Config()
+ if cfg == nil {
+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+ break
+ }
+
+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
+ cmds = append(cmds, uiutil.ReportError(err))
+ }
+
+ // XXX: Should this be in a separate goroutine?
+ go m.com.App.UpdateAgentModel(context.TODO())
+
+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
+ cmds = append(cmds, uiutil.ReportInfo(modelMsg))
+ m.dialog.CloseDialog(dialog.ModelsID)
+ }
+
+ return tea.Batch(cmds...)
+}
+
func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
var cmds []tea.Cmd
@@ -729,96 +822,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
// Route all messages to dialog if one is open.
if m.dialog.HasDialogs() {
- msg := m.dialog.Update(msg)
- if msg == nil {
- return tea.Batch(cmds...)
- }
-
- switch msg := msg.(type) {
- // Generic dialog messages
- case dialog.CloseMsg:
- m.dialog.CloseFrontDialog()
- if m.focus == uiFocusEditor {
- cmds = append(cmds, m.textarea.Focus())
- }
-
- // Session dialog messages
- case dialog.SessionSelectedMsg:
- m.dialog.CloseDialog(dialog.SessionsID)
- cmds = append(cmds, m.loadSession(msg.Session.ID))
-
- // Open dialog message
- case dialog.OpenDialogMsg:
- switch msg.DialogID {
- case dialog.SessionsID:
- if cmd := m.openSessionsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- case dialog.ModelsID:
- if cmd := m.openModelsDialog(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- default:
- // Unknown dialog
- break
- }
-
- m.dialog.CloseDialog(dialog.CommandsID)
-
- // Command dialog messages
- case dialog.ToggleYoloModeMsg:
- yolo := !m.com.App.Permissions.SkipRequests()
- m.com.App.Permissions.SetSkipRequests(yolo)
- m.setEditorPrompt(yolo)
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.NewSessionsMsg:
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
- break
- }
- m.newSession()
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.CompactMsg:
- if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
- break
- }
- err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
- if err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
- }
- case dialog.ToggleHelpMsg:
- m.status.ToggleHelp()
- m.dialog.CloseDialog(dialog.CommandsID)
- case dialog.QuitMsg:
- cmds = append(cmds, tea.Quit)
- case dialog.ModelSelectedMsg:
- if m.com.App.AgentCoordinator.IsBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
- break
- }
-
- // TODO: Validate model API and authentication here?
-
- cfg := m.com.Config()
- if cfg == nil {
- cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
- break
- }
-
- if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
- }
-
- // XXX: Should this be in a separate goroutine?
- go m.com.App.UpdateAgentModel(context.TODO())
-
- modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
- cmds = append(cmds, uiutil.ReportInfo(modelMsg))
- m.dialog.CloseDialog(dialog.ModelsID)
- }
-
- return tea.Batch(cmds...)
+ return m.handleDialogMsg(msg)
}
switch m.state {
@@ -1035,7 +1039,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
// Draw implements [uv.Drawable] and draws the UI model.
-func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
layout := m.generateLayout(area.Dx(), area.Dy())
if m.layout != layout {
@@ -1132,36 +1136,20 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
})
}
- // This needs to come last to overlay on top of everything
+ // This needs to come last to overlay on top of everything. We always pass
+ // the full screen bounds because the dialogs will position themselves
+ // accordingly.
if m.dialog.HasDialogs() {
- m.dialog.Draw(scr, area)
+ return m.dialog.Draw(scr, scr.Bounds())
}
-}
-// Cursor returns the cursor position and properties for the UI model. It
-// returns nil if the cursor should not be shown.
-func (m *UI) Cursor() *tea.Cursor {
- if m.layout.editor.Dy() <= 0 {
- // Don't show cursor if editor is not visible
- return nil
- }
- if m.dialog.HasDialogs() {
- if front := m.dialog.DialogLast(); front != nil {
- c, ok := front.(uiutil.Cursor)
- if ok {
- cur := c.Cursor()
- if cur != nil {
- pos := m.dialog.CenterPosition(m.layout.area, front.ID())
- cur.X += pos.Min.X
- cur.Y += pos.Min.Y
- return cur
- }
- }
- }
- return nil
- }
switch m.focus {
case uiFocusEditor:
+ if m.layout.editor.Dy() <= 0 {
+ // Don't show cursor if editor is not visible
+ return nil
+ }
+
if m.textarea.Focused() {
cur := m.textarea.Cursor()
cur.X++ // Adjust for app margins
@@ -1181,11 +1169,10 @@ func (m *UI) View() tea.View {
var v tea.View
v.AltScreen = true
v.BackgroundColor = m.com.Styles.Background
- v.Cursor = m.Cursor()
v.MouseMode = tea.MouseModeCellMotion
canvas := uv.NewScreenBuffer(m.width, m.height)
- m.Draw(canvas, canvas.Bounds())
+ v.Cursor = m.Draw(canvas, canvas.Bounds())
content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
contentLines := strings.Split(content, "\n")
@@ -1813,6 +1800,33 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
return tea.Batch(cmds...)
}
+// openDialog opens a dialog by its ID.
+func (m *UI) openDialog(id string) tea.Cmd {
+ var cmds []tea.Cmd
+ switch id {
+ case dialog.SessionsID:
+ if cmd := m.openSessionsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case dialog.ModelsID:
+ if cmd := m.openModelsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case dialog.CommandsID:
+ if cmd := m.openCommandsDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ case dialog.QuitID:
+ if cmd := m.openQuitDialog(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ default:
+ // Unknown dialog
+ break
+ }
+ return tea.Batch(cmds...)
+}
+
// openQuitDialog opens the quit confirmation dialog.
func (m *UI) openQuitDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.QuitID) {
@@ -1839,7 +1853,6 @@ func (m *UI) openModelsDialog() tea.Cmd {
return uiutil.ReportError(err)
}
- modelsDialog.SetSize(min(60, m.width-8), 30)
m.dialog.OpenDialog(modelsDialog)
return nil
@@ -1863,8 +1876,6 @@ func (m *UI) openCommandsDialog() tea.Cmd {
return uiutil.ReportError(err)
}
- // TODO: Get. Rid. Of. Magic numbers!
- commands.SetSize(min(120, m.width-8), 30)
m.dialog.OpenDialog(commands)
return nil
@@ -1890,8 +1901,6 @@ func (m *UI) openSessionsDialog() tea.Cmd {
return uiutil.ReportError(err)
}
- // TODO: Get. Rid. Of. Magic numbers!
- dialog.SetSize(min(120, m.width-8), 30)
m.dialog.OpenDialog(dialog)
return nil
@@ -1915,6 +1924,10 @@ func (m *UI) newSession() {
// handlePasteMsg handles a paste message.
func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
+ if m.dialog.HasDialogs() {
+ return m.handleDialogMsg(msg)
+ }
+
if m.focus != uiFocusEditor {
return nil
}