diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go new file mode 100644 index 0000000000000000000000000000000000000000..a152a125b179e7f14fd69361f236ce9d76e8effa --- /dev/null +++ b/internal/ui/dialog/actions.go @@ -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 +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index da95a252c044378690f3cf3f8cfbdde8a124349b..ec62320909445f96e3063747f6fea3d77628e6e4 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -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{}) }, }, { diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go index 2796ea16a78b24e0349ac30f1a4485271deae51e..f51ff0a4ee8390c8b889ccb1f3f3c2ba60c39532 100644 --- a/internal/ui/dialog/dialog.go +++ b/internal/ui/dialog/dialog.go @@ -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. diff --git a/internal/ui/dialog/messages.go b/internal/ui/dialog/messages.go deleted file mode 100644 index 8efc59240e83ea8137cdaf14a7c87f903b8683b5..0000000000000000000000000000000000000000 --- a/internal/ui/dialog/messages.go +++ /dev/null @@ -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 - } -) diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 1830a0880975b136446ac5bb45dae0d558fc2795..344db9cd3a2dbf96f73465d66449d2f60a9df7c3 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -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. diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 21ed6a5128f6fee85a2a3216ea303fa8d843258a..11173f0eaddb35a0b96aad6b1bf957ec86a37044 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -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]. diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index a60e900e12e5fc8578c077f0bc63257619cccc57..3773eba6c612d824c57f1886cd017a203bdca9d5 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -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]. diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index ddff75806e8345ca1140622739933c31055658e6..6d6852b7359d5f19d85349f34eff3b21c0510a05 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -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 diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d666cffc796ff309e75be5fc403bea01c4b049b5..adfb20c4be53946ce666719b5b70c417064eb3d0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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 }