on main help sign in
refactor(ui): dialog: improve command and session dialogs
7fad242Ayman Bagabas
internal/ui/dialog/commands.go+30−57
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/message"
19 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/list"
20 "github.com/charmbracelet/crush/internal/ui/list"
21+ "github.com/charmbracelet/crush/internal/ui/styles"
21 "github.com/charmbracelet/crush/internal/uicmd"
22 "github.com/charmbracelet/crush/internal/uicmd"
22 "github.com/charmbracelet/crush/internal/uiutil"
23 "github.com/charmbracelet/crush/internal/uiutil"
23)
24)
@@ -32,24 +33,6 @@ type SendMsg struct {
32 Attachments []message.Attachment
33 Attachments []message.Attachment
33}
34}
34
35
35−// Messages for commands
36−type (
37− SwitchSessionsMsg struct{}
38− NewSessionsMsg struct{}
39− SwitchModelMsg struct{}
40− QuitMsg struct{}
41− OpenFilePickerMsg struct{}
42− ToggleHelpMsg struct{}
43− ToggleCompactModeMsg struct{}
44− ToggleThinkingMsg struct{}
45− OpenReasoningDialogMsg struct{}
46− OpenExternalEditorMsg struct{}
47− ToggleYoloModeMsg struct{}
48− CompactMsg struct {
49− SessionID string
50− }
51−)
52−
53// Commands represents a dialog that shows available commands.
36// Commands represents a dialog that shows available commands.
54type Commands struct {
37type Commands struct {
55 com *common.Common
38 com *common.Common
@@ -149,10 +132,12 @@ func (c *Commands) ID() string {
149}
132}
150
133
151// Update implements Dialog.
134// Update implements Dialog.
152−func (c *Commands) Update(msg tea.Msg) tea.Cmd {
135+func (c *Commands) Update(msg tea.Msg) tea.Msg {
153 switch msg := msg.(type) {
136 switch msg := msg.(type) {
154 case tea.KeyPressMsg:
137 case tea.KeyPressMsg:
155 switch {
138 switch {
139+ case key.Matches(msg, c.keyMap.Close):
140+ return CloseMsg{}
156 case key.Matches(msg, c.keyMap.Previous):
141 case key.Matches(msg, c.keyMap.Previous):
157 c.list.Focus()
142 c.list.Focus()
158 c.list.SelectPrev()
143 c.list.SelectPrev()
@@ -175,8 +160,10 @@ func (c *Commands) Update(msg tea.Msg) tea.Cmd {
175 default:
160 default:
176 var cmd tea.Cmd
161 var cmd tea.Cmd
177 c.input, cmd = c.input.Update(msg)
162 c.input, cmd = c.input.Update(msg)
178− // Update the list filter
179− c.list.SetFilter(c.input.Value())
163+ value := c.input.Value()
164+ c.list.SetFilter(value)
165+ c.list.ScrollToTop()
166+ c.list.SetSelected(0)
180 return cmd
167 return cmd
181 }
168 }
182 }
169 }
@@ -195,14 +182,17 @@ func (c *Commands) ReloadMCPPrompts() tea.Cmd {
195
182
196// Cursor returns the cursor position relative to the dialog.
183// Cursor returns the cursor position relative to the dialog.
197func (c *Commands) Cursor() *tea.Cursor {
184func (c *Commands) Cursor() *tea.Cursor {
198− return c.input.Cursor()
185+ return InputCursor(c.com.Styles, c.input.Cursor())
199}
186}
200
187
201−// View implements [Dialog].
188+// radioView generates the command type selector radio buttons.
202−func (c *Commands) View() string {
189+func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
203− t := c.com.Styles
190+ if !hasUserCmds && !hasMCPPrompts {
191+ return ""
192+ }
193+
204 selectedFn := func(t uicmd.CommandType) string {
194 selectedFn := func(t uicmd.CommandType) string {
205− if t == c.selected {
195+ if t == selected {
206 return "◉ " + t.String()
196 return "◉ " + t.String()
207 }
197 }
208 return "○ " + t.String()
198 return "○ " + t.String()
@@ -211,46 +201,27 @@ func (c *Commands) View() string {
211 parts := []string{
201 parts := []string{
212 selectedFn(uicmd.SystemCommands),
202 selectedFn(uicmd.SystemCommands),
213 }
203 }
214− if len(c.userCmds) > 0 {
204+ if hasUserCmds {
215 parts = append(parts, selectedFn(uicmd.UserCommands))
205 parts = append(parts, selectedFn(uicmd.UserCommands))
216 }
206 }
217− if c.mcpPrompts.Len() > 0 {
207+ if hasMCPPrompts {
218 parts = append(parts, selectedFn(uicmd.MCPPrompts))
208 parts = append(parts, selectedFn(uicmd.MCPPrompts))
219 }
209 }
220
210
221 radio := strings.Join(parts, " ")
211 radio := strings.Join(parts, " ")
222− radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
212+ return t.Dialog.Commands.CommandTypeSelector.Render(radio)
223− if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
224− radio = " " + radio
225− }
213+}
226
214
215+// View implements [Dialog].
216+func (c *Commands) View() string {
217+ t := c.com.Styles
218+ radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
227 titleStyle := t.Dialog.Title
219 titleStyle := t.Dialog.Title
228− helpStyle := t.Dialog.HelpView
229 dialogStyle := t.Dialog.View.Width(c.width)
220 dialogStyle := t.Dialog.View.Width(c.width)
230− inputStyle := t.Dialog.InputPrompt
231− helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
232−
233 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
221 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
234 header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
222 header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
235− title := titleStyle.Render(header)
223+ return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
236− help := helpStyle.Render(c.help.View(c))
237− listContent := c.list.Render()
238− if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
239− // pad the list content to avoid jumping when navigating
240− listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
241− }
242−
243− content := strings.Join([]string{
244− title,
245− "",
246− inputStyle.Render(c.input.View()),
247− "",
248− c.list.Render(),
224+ c.input.View(), c.list.Render(), c.help.View(c))
249− "",
250− help,
251− }, "\n")
252−
253− return dialogStyle.Render(content)
254}
225}
255
226
256// ShortHelp implements [help.KeyMap].
227// ShortHelp implements [help.KeyMap].
@@ -316,7 +287,9 @@ func (c *Commands) setCommandType(commandType uicmd.CommandType) {
316 }
287 }
317
288
318 c.list.SetItems(commandItems...)
289 c.list.SetItems(commandItems...)
319− // Reset selection and filter
290+ c.list.SetSelected(0)
291+ c.list.SetFilter("")
292+ c.list.ScrollToTop()
320 c.list.SetSelected(0)
293 c.list.SetSelected(0)
321 c.input.SetValue("")
294 c.input.SetValue("")
322}
295}
@@ -485,7 +458,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
485 Description: "Quit",
458 Description: "Quit",
486 Shortcut: "ctrl+c",
459 Shortcut: "ctrl+c",
487 Handler: func(cmd uicmd.Command) tea.Cmd {
460 Handler: func(cmd uicmd.Command) tea.Cmd {
488− return uiutil.CmdHandler(QuitMsg{})
461+ return uiutil.CmdHandler(tea.QuitMsg{})
489 },
462 },
490 },
463 },
491 }...)
464 }...)
internal/ui/dialog/common.gonew file+56
@@ -0,0 +1,56 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ tea "charm.land/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+)
9+
10+// InputCursor adjusts the cursor position for an input field within a dialog.
11+func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
12+ if cur != nil {
13+ titleStyle := t.Dialog.Title
14+ dialogStyle := t.Dialog.View
15+ inputStyle := t.Dialog.InputPrompt
16+ // Adjust cursor position to account for dialog layout
17+ cur.X += inputStyle.GetBorderLeftSize() +
18+ inputStyle.GetMarginLeft() +
19+ inputStyle.GetPaddingLeft() +
20+ dialogStyle.GetBorderLeftSize() +
21+ dialogStyle.GetPaddingLeft() +
22+ dialogStyle.GetMarginLeft()
23+ cur.Y += titleStyle.GetVerticalFrameSize() +
24+ inputStyle.GetBorderTopSize() +
25+ inputStyle.GetMarginTop() +
26+ inputStyle.GetPaddingTop() +
27+ inputStyle.GetBorderBottomSize() +
28+ inputStyle.GetMarginBottom() +
29+ inputStyle.GetPaddingBottom() +
30+ dialogStyle.GetPaddingTop() +
31+ dialogStyle.GetMarginTop() +
32+ dialogStyle.GetBorderTopSize()
33+ }
34+ return cur
35+}
36+
37+// HeaderInputListHelpView generates a view for dialogs with a header, input,
38+// list, and help sections.
39+func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
40+ titleStyle := t.Dialog.Title
41+ helpStyle := t.Dialog.HelpView
42+ dialogStyle := t.Dialog.View.Width(width)
43+ inputStyle := t.Dialog.InputPrompt
44+ helpStyle = helpStyle.Width(width - dialogStyle.GetHorizontalFrameSize())
45+ listStyle := t.Dialog.List.Height(listHeight)
46+ listContent := listStyle.Render(list)
47+
48+ content := strings.Join([]string{
49+ titleStyle.Render(header),
50+ inputStyle.Render(input),
51+ listContent,
52+ helpStyle.Render(help),
53+ }, "\n")
54+
55+ return dialogStyle.Render(content)
56+}
internal/ui/dialog/dialog.go+36−18
@@ -17,7 +17,7 @@ var CloseKey = key.NewBinding(
17// Dialog is a component that can be displayed on top of the UI.
17// Dialog is a component that can be displayed on top of the UI.
18type Dialog interface {
18type Dialog interface {
19 ID() string
19 ID() string
20− Update(msg tea.Msg) tea.Cmd
20+ Update(msg tea.Msg) tea.Msg
21 View() string
21 View() string
22}
22}
23
23
@@ -71,6 +71,14 @@ func (d *Overlay) RemoveDialog(dialogID string) {
71 }
71 }
72}
72}
73
73
74+// RemoveFrontDialog removes the front dialog from the stack.
75+func (d *Overlay) RemoveFrontDialog() {
76+ if len(d.dialogs) == 0 {
77+ return
78+ }
79+ d.removeDialog(len(d.dialogs) - 1)
80+}
81+
74// Dialog returns the dialog with the specified ID, or nil if not found.
82// Dialog returns the dialog with the specified ID, or nil if not found.
75func (d *Overlay) Dialog(dialogID string) Dialog {
83func (d *Overlay) Dialog(dialogID string) Dialog {
76 for _, dialog := range d.dialogs {
84 for _, dialog := range d.dialogs {
@@ -81,6 +89,14 @@ func (d *Overlay) Dialog(dialogID string) Dialog {
81 return nil
89 return nil
82}
90}
83
91
92+// DialogLast returns the front dialog, or nil if there are no dialogs.
93+func (d *Overlay) DialogLast() Dialog {
94+ if len(d.dialogs) == 0 {
95+ return nil
96+ }
97+ return d.dialogs[len(d.dialogs)-1]
98+}
99+
84// BringToFront brings the dialog with the specified ID to the front.
100// BringToFront brings the dialog with the specified ID to the front.
85func (d *Overlay) BringToFront(dialogID string) {
101func (d *Overlay) BringToFront(dialogID string) {
86 for i, dialog := range d.dialogs {
102 for i, dialog := range d.dialogs {
@@ -94,38 +110,40 @@ func (d *Overlay) BringToFront(dialogID string) {
94}
110}
95
111
96// Update handles dialog updates.
112// Update handles dialog updates.
97−func (d *Overlay) Update(msg tea.Msg) (*Overlay, tea.Cmd) {
113+func (d *Overlay) Update(msg tea.Msg) tea.Msg {
98 if len(d.dialogs) == 0 {
114 if len(d.dialogs) == 0 {
99− return d, nil
115+ return nil
100 }
116 }
101
117
102 idx := len(d.dialogs) - 1 // active dialog is the last one
118 idx := len(d.dialogs) - 1 // active dialog is the last one
103 dialog := d.dialogs[idx]
119 dialog := d.dialogs[idx]
104− switch msg := msg.(type) {
120+ if dialog == nil {
105− case tea.KeyPressMsg:
106− if key.Matches(msg, CloseKey) {
107− // Close the current dialog
108− d.removeDialog(idx)
109− return d, nil
121+ return nil
110− }
111 }
122 }
112
123
113− if cmd := dialog.Update(msg); cmd != nil {
124+ return dialog.Update(msg)
114− // Close the current dialog
125+}
115− d.removeDialog(idx)
126+
116− return d, cmd
127+// CenterPosition calculates the centered position for the dialog.
128+func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
129+ dialog := d.Dialog(dialogID)
130+ if dialog == nil {
131+ return uv.Rectangle{}
117 }
132 }
133+ return d.centerPositionView(area, dialog.View())
134+}
118
135
119− return d, nil
136+func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
137+ viewWidth := lipgloss.Width(view)
138+ viewHeight := lipgloss.Height(view)
139+ return common.CenterRect(area, viewWidth, viewHeight)
120}
140}
121
141
122// Draw renders the overlay and its dialogs.
142// Draw renders the overlay and its dialogs.
123func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
143func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
124 for _, dialog := range d.dialogs {
144 for _, dialog := range d.dialogs {
125 view := dialog.View()
145 view := dialog.View()
126− viewWidth := lipgloss.Width(view)
146+ center := d.centerPositionView(area, view)
127− viewHeight := lipgloss.Height(view)
128− center := common.CenterRect(area, viewWidth, viewHeight)
129 if area.Overlaps(center) {
147 if area.Overlaps(center) {
130 uv.NewStyledString(view).Draw(scr, center)
148 uv.NewStyledString(view).Draw(scr, center)
131 }
149 }
internal/ui/dialog/messages.gonew file+34
@@ -0,0 +1,34 @@
1+package dialog
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/session"
6+)
7+
8+// CloseMsg is a message to close the current dialog.
9+type CloseMsg struct{}
10+
11+// QuitMsg is a message to quit the application.
12+type QuitMsg = tea.QuitMsg
13+
14+// SessionSelectedMsg is a message indicating a session has been selected.
15+type SessionSelectedMsg struct {
16+ Session session.Session
17+}
18+
19+// Messages for commands
20+type (
21+ SwitchSessionsMsg struct{}
22+ NewSessionsMsg struct{}
23+ SwitchModelMsg struct{}
24+ OpenFilePickerMsg struct{}
25+ ToggleHelpMsg struct{}
26+ ToggleCompactModeMsg struct{}
27+ ToggleThinkingMsg struct{}
28+ OpenReasoningDialogMsg struct{}
29+ OpenExternalEditorMsg struct{}
30+ ToggleYoloModeMsg struct{}
31+ CompactMsg struct {
32+ SessionID string
33+ }
34+)
internal/ui/dialog/quit.go+8−6
@@ -60,22 +60,24 @@ func (*Quit) ID() string {
60}
60}
61
61
62// Update implements [Model].
62// Update implements [Model].
63−func (q *Quit) Update(msg tea.Msg) tea.Cmd {
63+func (q *Quit) Update(msg tea.Msg) tea.Msg {
64 switch msg := msg.(type) {
64 switch msg := msg.(type) {
65 case tea.KeyPressMsg:
65 case tea.KeyPressMsg:
66 switch {
66 switch {
67+ case key.Matches(msg, q.keyMap.Close):
68+ return CloseMsg{}
67 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
69 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
68 q.selectedNo = !q.selectedNo
70 q.selectedNo = !q.selectedNo
69− return nil
71+ return CloseMsg{}
70 case key.Matches(msg, q.keyMap.EnterSpace):
72 case key.Matches(msg, q.keyMap.EnterSpace):
71 if !q.selectedNo {
73 if !q.selectedNo {
72− return tea.Quit
74+ return QuitMsg{}
73 }
75 }
74− return nil
76+ return CloseMsg{}
75 case key.Matches(msg, q.keyMap.Yes):
77 case key.Matches(msg, q.keyMap.Yes):
76− return tea.Quit
78+ return QuitMsg{}
77 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
79 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
78− return nil
80+ return CloseMsg{}
79 }
81 }
80 }
82 }
81
83
internal/ui/dialog/sessions.go+18−48
@@ -1,13 +1,10 @@
1package dialog
1package dialog
2
2
3import (
3import (
4− "strings"
5−
6 "charm.land/bubbles/v2/help"
4 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/key"
5 "charm.land/bubbles/v2/key"
8 "charm.land/bubbles/v2/textinput"
6 "charm.land/bubbles/v2/textinput"
9 tea "charm.land/bubbletea/v2"
7 tea "charm.land/bubbletea/v2"
10− "charm.land/lipgloss/v2"
11 "github.com/charmbracelet/crush/internal/session"
8 "github.com/charmbracelet/crush/internal/session"
12 "github.com/charmbracelet/crush/internal/ui/common"
9 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/list"
10 "github.com/charmbracelet/crush/internal/ui/list"
@@ -34,11 +31,6 @@ type Session struct {
34
31
35var _ Dialog = (*Session)(nil)
32var _ Dialog = (*Session)(nil)
36
33
37−// SessionSelectedMsg is a message sent when a session is selected.
38−type SessionSelectedMsg struct {
39− Session session.Session
40−}
41−
42// NewSessions creates a new Session dialog.
34// NewSessions creates a new Session dialog.
43func NewSessions(com *common.Common, sessions ...session.Session) *Session {
35func NewSessions(com *common.Common, sessions ...session.Session) *Session {
44 s := new(Session)
36 s := new(Session)
@@ -73,11 +65,6 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session {
73 return s
65 return s
74}
66}
75
67
76−// Cursor returns the cursor position relative to the dialog.
77−func (s *Session) Cursor() *tea.Cursor {
78− return s.input.Cursor()
79−}
80−
81// SetSize sets the size of the dialog.
68// SetSize sets the size of the dialog.
82func (s *Session) SetSize(width, height int) {
69func (s *Session) SetSize(width, height int) {
83 s.width = width
70 s.width = width
@@ -94,10 +81,12 @@ func (s *Session) ID() string {
94}
81}
95
82
96// Update implements Dialog.
83// Update implements Dialog.
97−func (s *Session) Update(msg tea.Msg) tea.Cmd {
84+func (s *Session) Update(msg tea.Msg) tea.Msg {
98 switch msg := msg.(type) {
85 switch msg := msg.(type) {
99 case tea.KeyPressMsg:
86 case tea.KeyPressMsg:
100 switch {
87 switch {
88+ case key.Matches(msg, s.keyMap.Close):
89+ return CloseMsg{}
101 case key.Matches(msg, s.keyMap.Previous):
90 case key.Matches(msg, s.keyMap.Previous):
102 s.list.Focus()
91 s.list.Focus()
103 s.list.SelectPrev()
92 s.list.SelectPrev()
@@ -109,48 +98,36 @@ func (s *Session) Update(msg tea.Msg) tea.Cmd {
109 case key.Matches(msg, s.keyMap.Select):
98 case key.Matches(msg, s.keyMap.Select):
110 if item := s.list.SelectedItem(); item != nil {
99 if item := s.list.SelectedItem(); item != nil {
111 sessionItem := item.(*SessionItem)
100 sessionItem := item.(*SessionItem)
112− return SessionSelectCmd(sessionItem.Session)
101+ return SessionSelectedMsg{sessionItem.Session}
113 }
102 }
114 default:
103 default:
115 var cmd tea.Cmd
104 var cmd tea.Cmd
116 s.input, cmd = s.input.Update(msg)
105 s.input, cmd = s.input.Update(msg)
117− s.list.SetFilter(s.input.Value())
106+ value := s.input.Value()
107+ s.list.SetFilter(value)
108+ s.list.ScrollToTop()
109+ s.list.SetSelected(0)
118 return cmd
110 return cmd
119 }
111 }
120 }
112 }
121 return nil
113 return nil
122}
114}
123
115
116+// Cursor returns the cursor position relative to the dialog.
117+func (s *Session) Cursor() *tea.Cursor {
118+ return InputCursor(s.com.Styles, s.input.Cursor())
119+}
120+
124// View implements [Dialog].
121// View implements [Dialog].
125func (s *Session) View() string {
122func (s *Session) View() string {
126 titleStyle := s.com.Styles.Dialog.Title
123 titleStyle := s.com.Styles.Dialog.Title
127− helpStyle := s.com.Styles.Dialog.HelpView
128 dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
124 dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
129− inputStyle := s.com.Styles.Dialog.InputPrompt
125+ header := common.DialogTitle(s.com.Styles, "Switch Session",
130− helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize())
126+ max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
131− listContent := s.list.Render()
127+ titleStyle.GetHorizontalFrameSize()))
132− if nlines := lipgloss.Height(listContent); nlines < s.list.Height() {
133− // pad the list content to avoid jumping when navigating
134− listContent += strings.Repeat("\n", max(0, s.list.Height()-nlines))
135− }
136
128
137− content := strings.Join([]string{
129+ return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
138− titleStyle.Render(
139− common.DialogTitle(
140− s.com.Styles,
141− "Switch Session",
142− max(0, s.width-
143− dialogStyle.GetHorizontalFrameSize()-
144− titleStyle.GetHorizontalFrameSize()))),
145− "",
146− inputStyle.Render(s.input.View()),
147− "",
148− listContent,
149− "",
150− helpStyle.Render(s.help.View(s)),
130+ s.input.View(), s.list.Render(), s.help.View(s))
151− }, "\n")
152−
153− return dialogStyle.Render(content)
154}
131}
155
132
156// ShortHelp implements [help.KeyMap].
133// ShortHelp implements [help.KeyMap].
@@ -181,10 +158,3 @@ func (s *Session) FullHelp() [][]key.Binding {
181 }
158 }
182 return m
159 return m
183}
160}
184−
185−// SessionSelectCmd creates a command that sends a SessionSelectMsg.
186−func SessionSelectCmd(s session.Session) tea.Cmd {
187− return func() tea.Msg {
188− return SessionSelectedMsg{Session: s}
189− }
190−}
internal/ui/model/ui.go+104−65
@@ -20,11 +20,11 @@ import (
20 "github.com/charmbracelet/crush/internal/message"
20 "github.com/charmbracelet/crush/internal/message"
21 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/pubsub"
22 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/session"
23− "github.com/charmbracelet/crush/internal/tui/util"
24 "github.com/charmbracelet/crush/internal/ui/common"
23 "github.com/charmbracelet/crush/internal/ui/common"
25 "github.com/charmbracelet/crush/internal/ui/dialog"
24 "github.com/charmbracelet/crush/internal/ui/dialog"
26 "github.com/charmbracelet/crush/internal/ui/logo"
25 "github.com/charmbracelet/crush/internal/ui/logo"
27 "github.com/charmbracelet/crush/internal/ui/styles"
26 "github.com/charmbracelet/crush/internal/ui/styles"
27+ "github.com/charmbracelet/crush/internal/uiutil"
28 "github.com/charmbracelet/crush/internal/version"
28 "github.com/charmbracelet/crush/internal/version"
29 uv "github.com/charmbracelet/ultraviolet"
29 uv "github.com/charmbracelet/ultraviolet"
30 "github.com/charmbracelet/ultraviolet/screen"
30 "github.com/charmbracelet/ultraviolet/screen"
@@ -194,12 +194,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194 // TODO: Get. Rid. Of. Magic numbers!
194 // TODO: Get. Rid. Of. Magic numbers!
195 sessions.SetSize(min(120, m.width-8), 30)
195 sessions.SetSize(min(120, m.width-8), 30)
196 m.dialog.AddDialog(sessions)
196 m.dialog.AddDialog(sessions)
197− case dialog.SessionSelectedMsg:
198− m.dialog.RemoveDialog(dialog.SessionsID)
199− cmds = append(cmds,
200− m.loadSession(msg.Session.ID),
201− m.loadSessionFiles(msg.Session.ID),
202− )
203 case sessionLoadedMsg:
197 case sessionLoadedMsg:
204 m.state = uiChat
198 m.state = uiChat
205 m.session = &msg.sess
199 m.session = &msg.sess
@@ -330,15 +324,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
330 }
324 }
331 case tea.KeyPressMsg:
325 case tea.KeyPressMsg:
332 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
326 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
333−
334− // Command dialog messages
335− // TODO: Properly structure and handle these messages
336− case dialog.ToggleYoloModeMsg:
337− m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests())
338− m.dialog.RemoveDialog(dialog.CommandsID)
339− case dialog.SwitchSessionsMsg:
340− cmds = append(cmds, m.loadSessionsCmd)
341− m.dialog.RemoveDialog(dialog.CommandsID)
342 }
327 }
343
328
344 // This logic gets triggered on any message type, but should it?
329 // This logic gets triggered on any message type, but should it?
@@ -384,7 +369,6 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
384 return true
369 return true
385 }
370 }
386 switch {
371 switch {
387− case key.Matches(msg, m.keyMap.Tab):
388 case key.Matches(msg, m.keyMap.Help):
372 case key.Matches(msg, m.keyMap.Help):
389 m.help.ShowAll = !m.help.ShowAll
373 m.help.ShowAll = !m.help.ShowAll
390 m.updateLayoutAndSize()
374 m.updateLayoutAndSize()
@@ -400,7 +384,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
400 }
384 }
401 commands, err := dialog.NewCommands(m.com, sessionID)
385 commands, err := dialog.NewCommands(m.com, sessionID)
402 if err != nil {
386 if err != nil {
403− cmds = append(cmds, util.ReportError(err))
387+ cmds = append(cmds, uiutil.ReportError(err))
404 } else {
388 } else {
405 // TODO: Get. Rid. Of. Magic numbers!
389 // TODO: Get. Rid. Of. Magic numbers!
406 commands.SetSize(min(120, m.width-8), 30)
390 commands.SetSize(min(120, m.width-8), 30)
@@ -427,64 +411,101 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
427 return cmds
411 return cmds
428 }
412 }
429
413
430− updatedDialog, cmd := m.dialog.Update(msg)
414+ msg := m.dialog.Update(msg)
431− m.dialog = updatedDialog
432− if cmd != nil {
415+ if msg == nil {
433− cmds = append(cmds, cmd)
416+ return cmds
434 }
417 }
418+
419+ switch msg := msg.(type) {
420+ // Generic dialog messages
421+ case dialog.CloseMsg:
422+ m.dialog.RemoveFrontDialog()
423+ // Session dialog messages
424+ case dialog.SessionSelectedMsg:
425+ m.dialog.RemoveDialog(dialog.SessionsID)
426+ cmds = append(cmds,
427+ m.loadSession(msg.Session.ID),
428+ m.loadSessionFiles(msg.Session.ID),
429+ )
430+ // Command dialog messages
431+ case dialog.ToggleYoloModeMsg:
432+ m.com.App.Permissions.SetSkipRequests(!m.com.App.Permissions.SkipRequests())
433+ m.dialog.RemoveDialog(dialog.CommandsID)
434+ case dialog.SwitchSessionsMsg:
435+ cmds = append(cmds, m.loadSessionsCmd)
436+ m.dialog.RemoveDialog(dialog.CommandsID)
437+ case dialog.CompactMsg:
438+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
439+ if err != nil {
440+ cmds = append(cmds, uiutil.ReportError(err))
441+ }
442+ case dialog.ToggleHelpMsg:
443+ m.help.ShowAll = !m.help.ShowAll
444+ case dialog.QuitMsg:
445+ cmds = append(cmds, tea.Quit)
446+ }
447+
435 return cmds
448 return cmds
436 }
449 }
437
450
438 switch m.state {
451 switch m.state {
439 case uiChat:
452 case uiChat:
440− switch {
453+ switch m.focus {
454+ case uiFocusEditor:
455+ switch {
441− case key.Matches(msg, m.keyMap.Tab):
456+ case key.Matches(msg, m.keyMap.Tab):
442− if m.focus == uiFocusMain {
443− m.focus = uiFocusEditor
444− cmds = append(cmds, m.textarea.Focus())
445− m.chat.Blur()
446− } else {
447 m.focus = uiFocusMain
457 m.focus = uiFocusMain
448 m.textarea.Blur()
458 m.textarea.Blur()
449 m.chat.Focus()
459 m.chat.Focus()
450 m.chat.SetSelected(m.chat.Len() - 1)
460 m.chat.SetSelected(m.chat.Len() - 1)
461+ default:
462+ handleGlobalKeys(msg)
451 }
463 }
464+ case uiFocusMain:
465+ switch {
466+ case key.Matches(msg, m.keyMap.Tab):
467+ m.focus = uiFocusEditor
468+ cmds = append(cmds, m.textarea.Focus())
469+ m.chat.Blur()
452− case key.Matches(msg, m.keyMap.Chat.Up):
470+ case key.Matches(msg, m.keyMap.Chat.Up):
453− m.chat.ScrollBy(-1)
471+ m.chat.ScrollBy(-1)
472+ if !m.chat.SelectedItemInView() {
473+ m.chat.SelectPrev()
474+ m.chat.ScrollToSelected()
475+ }
476+ case key.Matches(msg, m.keyMap.Chat.Down):
477+ m.chat.ScrollBy(1)
454− if !m.chat.SelectedItemInView() {
478+ if !m.chat.SelectedItemInView() {
479+ m.chat.SelectNext()
480+ m.chat.ScrollToSelected()
481+ }
482+ case key.Matches(msg, m.keyMap.Chat.UpOneItem):
455 m.chat.SelectPrev()
483 m.chat.SelectPrev()
456 m.chat.ScrollToSelected()
484 m.chat.ScrollToSelected()
457− }
458− case key.Matches(msg, m.keyMap.Chat.Down):
485+ case key.Matches(msg, m.keyMap.Chat.DownOneItem):
459− m.chat.ScrollBy(1)
460− if !m.chat.SelectedItemInView() {
461 m.chat.SelectNext()
486 m.chat.SelectNext()
462 m.chat.ScrollToSelected()
487 m.chat.ScrollToSelected()
488+ case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
489+ m.chat.ScrollBy(-m.chat.Height() / 2)
490+ m.chat.SelectFirstInView()
491+ case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
492+ m.chat.ScrollBy(m.chat.Height() / 2)
493+ m.chat.SelectLastInView()
494+ case key.Matches(msg, m.keyMap.Chat.PageUp):
495+ m.chat.ScrollBy(-m.chat.Height())
496+ m.chat.SelectFirstInView()
497+ case key.Matches(msg, m.keyMap.Chat.PageDown):
498+ m.chat.ScrollBy(m.chat.Height())
499+ m.chat.SelectLastInView()
500+ case key.Matches(msg, m.keyMap.Chat.Home):
501+ m.chat.ScrollToTop()
502+ m.chat.SelectFirst()
503+ case key.Matches(msg, m.keyMap.Chat.End):
504+ m.chat.ScrollToBottom()
505+ m.chat.SelectLast()
506+ default:
507+ handleGlobalKeys(msg)
463 }
508 }
464− case key.Matches(msg, m.keyMap.Chat.UpOneItem):
465− m.chat.SelectPrev()
466− m.chat.ScrollToSelected()
467− case key.Matches(msg, m.keyMap.Chat.DownOneItem):
468− m.chat.SelectNext()
469− m.chat.ScrollToSelected()
470− case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
471− m.chat.ScrollBy(-m.chat.Height() / 2)
472− m.chat.SelectFirstInView()
473− case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
474− m.chat.ScrollBy(m.chat.Height() / 2)
475− m.chat.SelectLastInView()
476− case key.Matches(msg, m.keyMap.Chat.PageUp):
477− m.chat.ScrollBy(-m.chat.Height())
478− m.chat.SelectFirstInView()
479− case key.Matches(msg, m.keyMap.Chat.PageDown):
480− m.chat.ScrollBy(m.chat.Height())
481− m.chat.SelectLastInView()
482− case key.Matches(msg, m.keyMap.Chat.Home):
483− m.chat.ScrollToTop()
484− m.chat.SelectFirst()
485− case key.Matches(msg, m.keyMap.Chat.End):
486− m.chat.ScrollToBottom()
487− m.chat.SelectLast()
488 default:
509 default:
489 handleGlobalKeys(msg)
510 handleGlobalKeys(msg)
490 }
511 }
@@ -588,11 +609,29 @@ func (m *UI) Cursor() *tea.Cursor {
588 // Don't show cursor if editor is not visible
609 // Don't show cursor if editor is not visible
589 return nil
610 return nil
590 }
611 }
612+ if m.dialog.HasDialogs() {
613+ if front := m.dialog.DialogLast(); front != nil {
614+ c, ok := front.(uiutil.Cursor)
615+ if ok {
616+ cur := c.Cursor()
617+ if cur != nil {
618+ pos := m.dialog.CenterPosition(m.layout.area, front.ID())
619+ cur.X += pos.Min.X
620+ cur.Y += pos.Min.Y
621+ return cur
622+ }
623+ }
624+ }
625+ return nil
626+ }
627+ switch m.focus {
628+ case uiFocusEditor:
591− if m.focus == uiFocusEditor && m.textarea.Focused() {
629+ if m.textarea.Focused() {
592− cur := m.textarea.Cursor()
630+ cur := m.textarea.Cursor()
593− cur.X++ // Adjust for app margins
631+ cur.X++ // Adjust for app margins
594− cur.Y += m.layout.editor.Min.Y
632+ cur.Y += m.layout.editor.Min.Y
595− return cur
633+ return cur
634+ }
596 }
635 }
597 return nil
636 return nil
598}
637}
internal/ui/styles/styles.go+5−1
@@ -277,6 +277,8 @@ type Styles struct {
277 SelectedItem lipgloss.Style
277 SelectedItem lipgloss.Style
278 InputPrompt lipgloss.Style
278 InputPrompt lipgloss.Style
279
279
280+ List lipgloss.Style
281+
280 Commands struct {
282 Commands struct {
281 CommandTypeSelector lipgloss.Style
283 CommandTypeSelector lipgloss.Style
282 }
284 }
@@ -909,7 +911,9 @@ func DefaultStyles() Styles {
909 s.Dialog.Help.FullSeparator = base.Foreground(border)
911 s.Dialog.Help.FullSeparator = base.Foreground(border)
910 s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
912 s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
911 s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
913 s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
912− s.Dialog.InputPrompt = base.Padding(0, 1)
914+ s.Dialog.InputPrompt = base.Margin(1, 1)
915+
916+ s.Dialog.List = base.Margin(0, 0, 1, 0)
913
917
914 s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
918 s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
915
919