on main help sign in
refactor(ui): dialog: message and draw handling (#1822)
commitfcd1b83168efee74b3cf34f0b33c0a5f2a2bbf2f authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 12 Jan 2026, 05:13 (9 months ago) parentc4add9e
9 files changed+348−306
internal/ui/dialog/actions.go+48 internal/ui/dialog/commands.go+51−42 internal/ui/dialog/dialog.go+28−19 internal/ui/dialog/messages.go−44 internal/ui/dialog/models.go+30−29 internal/ui/dialog/quit.go+16−11 internal/ui/dialog/sessions.go+34−33 internal/ui/dialog/sessions_item.go+5−5 internal/ui/model/ui.go+136−123
internal/ui/dialog/actions.gonew file+48
@@ -0,0 +1,48 @@
1+package dialog
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/config"
6+ "github.com/charmbracelet/crush/internal/session"
7+)
8+
9+// ActionClose is a message to close the current dialog.
10+type ActionClose struct{}
11+
12+// ActionQuit is a message to quit the application.
13+type ActionQuit = tea.QuitMsg
14+
15+// ActionOpenDialog is a message to open a dialog.
16+type ActionOpenDialog struct {
17+ DialogID string
18+}
19+
20+// ActionSelectSession is a message indicating a session has been selected.
21+type ActionSelectSession struct {
22+ Session session.Session
23+}
24+
25+// ActionSelectModel is a message indicating a model has been selected.
26+type ActionSelectModel struct {
27+ Model config.SelectedModel
28+ ModelType config.SelectedModelType
29+}
30+
31+// Messages for commands
32+type (
33+ ActionNewSession struct{}
34+ ActionToggleHelp struct{}
35+ ActionToggleCompactMode struct{}
36+ ActionToggleThinking struct{}
37+ ActionExternalEditor struct{}
38+ ActionToggleYoloMode struct{}
39+ ActionSummarize struct {
40+ SessionID string
41+ }
42+)
43+
44+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
45+// Bubble Tea program loop.
46+type ActionCmd struct {
47+ Cmd tea.Cmd
48+}
internal/ui/dialog/commands.go+51−42
@@ -21,6 +21,8 @@ import (
2121 "github.com/charmbracelet/crush/internal/ui/styles"
2222 "github.com/charmbracelet/crush/internal/uicmd"
2323 "github.com/charmbracelet/crush/internal/uiutil"
24+ uv "github.com/charmbracelet/ultraviolet"
25+ "github.com/charmbracelet/x/ansi"
2426)
2527
2628// CommandsID is the identifier for the commands dialog.
@@ -43,10 +45,11 @@ type Commands struct {
4345 userCmds []uicmd.Command
4446 mcpPrompts *csync.Slice[uicmd.Command]
4547
46− help help.Model
47− input textinput.Model
48− list *list.FilterableList
49− width, height int
48+ help help.Model
49+ input textinput.Model
50+ list *list.FilterableList
51+
52+ width int
5053}
5154
5255var _ Dialog = (*Commands)(nil)
@@ -114,33 +117,18 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
114117 return c, nil
115118}
116119
117−// SetSize sets the size of the dialog.
118−func (c *Commands) SetSize(width, height int) {
119− t := c.com.Styles
120− c.width = width
121− c.height = height
122− innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
123− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
124− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
125− t.Dialog.HelpView.GetVerticalFrameSize() +
126− t.Dialog.View.GetVerticalFrameSize()
127− c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
128− c.list.SetSize(innerWidth, height-heightOffset)
129− c.help.SetWidth(width)
130−}
131−
132120// ID implements Dialog.
133121func (c *Commands) ID() string {
134122 return CommandsID
135123}
136124
137−// Update implements Dialog.
138−func (c *Commands) Update(msg tea.Msg) tea.Msg {
125+// HandleMsg implements Dialog.
126+func (c *Commands) HandleMsg(msg tea.Msg) Action {
139127 switch msg := msg.(type) {
140128 case tea.KeyPressMsg:
141129 switch {
142130 case key.Matches(msg, c.keyMap.Close):
143− return CloseMsg{}
131+ return ActionClose{}
144132 case key.Matches(msg, c.keyMap.Previous):
145133 c.list.Focus()
146134 if c.list.IsSelectedFirst() {
@@ -181,9 +169,7 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg {
181169 c.list.SetFilter(value)
182170 c.list.ScrollToTop()
183171 c.list.SetSelected(0)
184− if cmd != nil {
185− return cmd()
186− }
172+ return ActionCmd{cmd}
187173 }
188174 }
189175 return nil
@@ -231,16 +217,35 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
231217 return strings.Join(parts, " ")
232218}
233219
234−// View implements [Dialog].
235−func (c *Commands) View() string {
220+// Draw implements [Dialog].
221+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
236222 t := c.com.Styles
223+ width := max(0, min(100, area.Dx()))
224+ height := max(0, min(30, area.Dy()))
225+ c.width = width
226+ // TODO: Why do we need this 2?
227+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
228+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
229+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
230+ t.Dialog.HelpView.GetVerticalFrameSize() +
231+ // TODO: Why do we need this 2?
232+ t.Dialog.View.GetVerticalFrameSize() + 2
233+ c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
234+ c.list.SetSize(innerWidth, height-heightOffset)
235+ c.help.SetWidth(innerWidth)
236+
237237 radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
238238 titleStyle := t.Dialog.Title
239− dialogStyle := t.Dialog.View.Width(c.width)
239+ dialogStyle := t.Dialog.View.Width(width)
240240 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
241− header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
242− return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
243− c.input.View(), c.list.Render(), c.help.View(c))
241+ helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
242+ header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
243+ view := HeaderInputListHelpView(t, width, c.list.Height(), header,
244+ c.input.View(), c.list.Render(), helpView)
245+
246+ cur := c.Cursor()
247+ DrawCenterCursor(scr, area, view, cur)
248+ return cur
244249}
245250
246251// ShortHelp implements [help.KeyMap].
@@ -318,7 +323,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
318323 Description: "start a new session",
319324 Shortcut: "ctrl+n",
320325 Handler: func(cmd uicmd.Command) tea.Cmd {
321− return uiutil.CmdHandler(NewSessionsMsg{})
326+ return uiutil.CmdHandler(ActionNewSession{})
322327 },
323328 },
324329 {
@@ -327,7 +332,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
327332 Description: "Switch to a different session",
328333 Shortcut: "ctrl+s",
329334 Handler: func(cmd uicmd.Command) tea.Cmd {
330− return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
335+ return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
331336 },
332337 },
333338 {
@@ -337,7 +342,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
337342 // FIXME: The shortcut might get updated if enhanced keyboard is supported.
338343 Shortcut: "ctrl+l",
339344 Handler: func(cmd uicmd.Command) tea.Cmd {
340− return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
345+ return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
341346 },
342347 },
343348 }
@@ -349,7 +354,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
349354 Title: "Summarize Session",
350355 Description: "Summarize the current session and create a new one with the summary",
351356 Handler: func(cmd uicmd.Command) tea.Cmd {
352− return uiutil.CmdHandler(CompactMsg{
357+ return uiutil.CmdHandler(ActionSummarize{
353358 SessionID: c.sessionID,
354359 })
355360 },
@@ -375,7 +380,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
375380 Title: status + " Thinking Mode",
376381 Description: "Toggle model thinking for reasoning-capable models",
377382 Handler: func(cmd uicmd.Command) tea.Cmd {
378− return uiutil.CmdHandler(ToggleThinkingMsg{})
383+ return uiutil.CmdHandler(ActionToggleThinking{})
379384 },
380385 })
381386 }
@@ -387,7 +392,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
387392 Title: "Select Reasoning Effort",
388393 Description: "Choose reasoning effort level (low/medium/high)",
389394 Handler: func(cmd uicmd.Command) tea.Cmd {
390− return uiutil.CmdHandler(OpenReasoningDialogMsg{})
395+ return uiutil.CmdHandler(ActionOpenDialog{
396+ // TODO: Pass reasoning dialog id
397+ })
391398 },
392399 })
393400 }
@@ -401,7 +408,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
401408 Title: "Toggle Sidebar",
402409 Description: "Toggle between compact and normal layout",
403410 Handler: func(cmd uicmd.Command) tea.Cmd {
404− return uiutil.CmdHandler(ToggleCompactModeMsg{})
411+ return uiutil.CmdHandler(ActionToggleCompactMode{})
405412 },
406413 })
407414 }
@@ -416,7 +423,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
416423 Shortcut: "ctrl+f",
417424 Description: "Open file picker",
418425 Handler: func(cmd uicmd.Command) tea.Cmd {
419− return uiutil.CmdHandler(OpenFilePickerMsg{})
426+ return uiutil.CmdHandler(ActionOpenDialog{
427+ // TODO: Pass file picker dialog id
428+ })
420429 },
421430 })
422431 }
@@ -431,7 +440,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
431440 Shortcut: "ctrl+o",
432441 Description: "Open external editor to compose message",
433442 Handler: func(cmd uicmd.Command) tea.Cmd {
434− return uiutil.CmdHandler(OpenExternalEditorMsg{})
443+ return uiutil.CmdHandler(ActionExternalEditor{})
435444 },
436445 })
437446 }
@@ -442,7 +451,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
442451 Title: "Toggle Yolo Mode",
443452 Description: "Toggle yolo mode",
444453 Handler: func(cmd uicmd.Command) tea.Cmd {
445− return uiutil.CmdHandler(ToggleYoloModeMsg{})
454+ return uiutil.CmdHandler(ActionToggleYoloMode{})
446455 },
447456 },
448457 {
@@ -451,7 +460,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
451460 Shortcut: "ctrl+g",
452461 Description: "Toggle help",
453462 Handler: func(cmd uicmd.Command) tea.Cmd {
454− return uiutil.CmdHandler(ToggleHelpMsg{})
463+ return uiutil.CmdHandler(ActionToggleHelp{})
455464 },
456465 },
457466 {
internal/ui/dialog/dialog.go+28−19
@@ -14,11 +14,19 @@ var CloseKey = key.NewBinding(
1414 key.WithHelp("esc", "exit"),
1515)
1616
17+// Action represents an action taken in a dialog after handling a message.
18+type Action interface{}
19+
1720// Dialog is a component that can be displayed on top of the UI.
1821type Dialog interface {
22+ // ID returns the unique identifier of the dialog.
1923 ID() string
20− Update(msg tea.Msg) tea.Msg
21− View() string
24+ // HandleMsg processes a message and returns an action. An [Action] can be
25+ // anything and the caller is responsible for handling it appropriately.
26+ HandleMsg(msg tea.Msg) Action
27+ // Draw draws the dialog onto the provided screen within the specified area
28+ // and returns the desired cursor position on the screen.
29+ Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
2230}
2331
2432// Overlay manages multiple dialogs as an overlay.
@@ -113,33 +121,34 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
113121 return nil
114122 }
115123
116− return dialog.Update(msg)
124+ return dialog.HandleMsg(msg)
117125}
118126
119−// CenterPosition calculates the centered position for the dialog.
120−func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
121− dialog := d.Dialog(dialogID)
122− if dialog == nil {
123− return uv.Rectangle{}
127+// DrawCenterCursor draws the given string view centered in the screen area and
128+// adjusts the cursor position accordingly.
129+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
130+ width, height := lipgloss.Size(view)
131+ center := common.CenterRect(area, width, height)
132+ if cur != nil {
133+ cur.X += center.Min.X
134+ cur.Y += center.Min.Y
124135 }
125− return d.centerPositionView(area, dialog.View())
136+
137+ uv.NewStyledString(view).Draw(scr, center)
126138}
127139
128−func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
129− viewWidth := lipgloss.Width(view)
130− viewHeight := lipgloss.Height(view)
131− return common.CenterRect(area, viewWidth, viewHeight)
140+// DrawCenter draws the given string view centered in the screen area.
141+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
142+ DrawCenterCursor(scr, area, view, nil)
132143}
133144
134145// Draw renders the overlay and its dialogs.
135−func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
146+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
147+ var cur *tea.Cursor
136148 for _, dialog := range d.dialogs {
137− view := dialog.View()
138− center := d.centerPositionView(area, view)
139− if area.Overlaps(center) {
140− uv.NewStyledString(view).Draw(scr, center)
141− }
149+ cur = dialog.Draw(scr, area)
142150 }
151+ return cur
143152}
144153
145154// removeDialog removes a dialog from the stack.
internal/ui/dialog/messages.godeleted−44
@@ -1,44 +0,0 @@
1−package dialog
2−
3−import (
4− tea "charm.land/bubbletea/v2"
5− "github.com/charmbracelet/crush/internal/config"
6− "github.com/charmbracelet/crush/internal/session"
7−)
8−
9−// CloseMsg is a message to close the current dialog.
10−type CloseMsg struct{}
11−
12−// QuitMsg is a message to quit the application.
13−type QuitMsg = tea.QuitMsg
14−
15−// OpenDialogMsg is a message to open a dialog.
16−type OpenDialogMsg struct {
17− DialogID string
18−}
19−
20−// SessionSelectedMsg is a message indicating a session has been selected.
21−type SessionSelectedMsg struct {
22− Session session.Session
23−}
24−
25−// ModelSelectedMsg is a message indicating a model has been selected.
26−type ModelSelectedMsg struct {
27− Model config.SelectedModel
28− ModelType config.SelectedModelType
29−}
30−
31−// Messages for commands
32−type (
33− NewSessionsMsg struct{}
34− OpenFilePickerMsg struct{}
35− ToggleHelpMsg struct{}
36− ToggleCompactModeMsg struct{}
37− ToggleThinkingMsg struct{}
38− OpenReasoningDialogMsg struct{}
39− OpenExternalEditorMsg struct{}
40− ToggleYoloModeMsg struct{}
41− CompactMsg struct {
42− SessionID string
43− }
44−)
internal/ui/dialog/models.go+30−29
@@ -15,6 +15,8 @@ import (
1515 "github.com/charmbracelet/crush/internal/config"
1616 "github.com/charmbracelet/crush/internal/ui/common"
1717 "github.com/charmbracelet/crush/internal/uiutil"
18+ uv "github.com/charmbracelet/ultraviolet"
19+ "github.com/charmbracelet/x/ansi"
1820)
1921
2022// ModelType represents the type of model to select.
@@ -76,8 +78,6 @@ type Models struct {
7678 modelType ModelType
7779 providers []catwalk.Provider
7880
79− width, height int
80−
8181 keyMap struct {
8282 Tab key.Binding
8383 UpDown key.Binding
@@ -147,33 +147,18 @@ func NewModels(com *common.Common) (*Models, error) {
147147 return m, nil
148148}
149149
150−// SetSize sets the size of the dialog.
151−func (m *Models) SetSize(width, height int) {
152− t := m.com.Styles
153− m.width = width
154− m.height = height
155− innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
156− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
157− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
158− t.Dialog.HelpView.GetVerticalFrameSize() +
159− t.Dialog.View.GetVerticalFrameSize()
160− m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
161− m.list.SetSize(innerWidth, height-heightOffset)
162− m.help.SetWidth(width)
163−}
164−
165150// ID implements Dialog.
166151func (m *Models) ID() string {
167152 return ModelsID
168153}
169154
170−// Update implements Dialog.
171−func (m *Models) Update(msg tea.Msg) tea.Msg {
155+// HandleMsg implements Dialog.
156+func (m *Models) HandleMsg(msg tea.Msg) Action {
172157 switch msg := msg.(type) {
173158 case tea.KeyPressMsg:
174159 switch {
175160 case key.Matches(msg, m.keyMap.Close):
176− return CloseMsg{}
161+ return ActionClose{}
177162 case key.Matches(msg, m.keyMap.Previous):
178163 m.list.Focus()
179164 if m.list.IsSelectedFirst() {
@@ -203,7 +188,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
203188 break
204189 }
205190
206− return ModelSelectedMsg{
191+ return ActionSelectModel{
207192 Model: modelItem.SelectedModel(),
208193 ModelType: modelItem.SelectedModelType(),
209194 }
@@ -222,9 +207,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
222207 value := m.input.Value()
223208 m.list.SetFilter(value)
224209 m.list.ScrollToSelected()
225− if cmd != nil {
226− return cmd()
227− }
210+ return ActionCmd{cmd}
228211 }
229212 }
230213 return nil
@@ -255,9 +238,22 @@ func (m *Models) modelTypeRadioView() string {
255238 smallRadio, textStyle.Render(ModelTypeSmall.String()))
256239}
257240
258−// View implements Dialog.
259−func (m *Models) View() string {
241+// Draw implements [Dialog].
242+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
260243 t := m.com.Styles
244+ width := max(0, min(60, area.Dx()))
245+ height := max(0, min(30, area.Dy()))
246+ // TODO: Why do we need this 2?
247+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
248+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
249+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
250+ t.Dialog.HelpView.GetVerticalFrameSize() +
251+ // TODO: Why do we need this 2?
252+ t.Dialog.View.GetVerticalFrameSize() + 2
253+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
254+ m.list.SetSize(innerWidth, height-heightOffset)
255+ m.help.SetWidth(innerWidth)
256+
261257 titleStyle := t.Dialog.Title
262258 dialogStyle := t.Dialog.View
263259
@@ -266,10 +262,15 @@ func (m *Models) View() string {
266262 headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
267263 dialogStyle.GetHorizontalFrameSize()
268264
269− header := common.DialogTitle(t, "Switch Model", m.width-headerOffset) + radios
265+ header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
266+
267+ helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
268+ view := HeaderInputListHelpView(t, width, m.list.Height(), header,
269+ m.input.View(), m.list.Render(), helpView)
270270
271− return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
272− m.input.View(), m.list.Render(), m.help.View(m))
271+ cur := m.Cursor()
272+ DrawCenterCursor(scr, area, view, cur)
273+ return cur
273274}
274275
275276// ShortHelp returns the short help view.
internal/ui/dialog/quit.go+16−11
@@ -5,6 +5,7 @@ import (
55 tea "charm.land/bubbletea/v2"
66 "charm.land/lipgloss/v2"
77 "github.com/charmbracelet/crush/internal/ui/common"
8+ uv "github.com/charmbracelet/ultraviolet"
89)
910
1011// QuitID is the identifier for the quit dialog.
@@ -25,6 +26,8 @@ type Quit struct {
2526 }
2627}
2728
29+var _ Dialog = (*Quit)(nil)
30+
2831// NewQuit creates a new quit confirmation dialog.
2932func NewQuit(com *common.Common) *Quit {
3033 q := &Quit{
@@ -64,34 +67,34 @@ func (*Quit) ID() string {
6467 return QuitID
6568}
6669
67−// Update implements [Model].
68−func (q *Quit) Update(msg tea.Msg) tea.Msg {
70+// HandleMsg implements [Model].
71+func (q *Quit) HandleMsg(msg tea.Msg) Action {
6972 switch msg := msg.(type) {
7073 case tea.KeyPressMsg:
7174 switch {
7275 case key.Matches(msg, q.keyMap.Quit):
73− return QuitMsg{}
76+ return ActionQuit{}
7477 case key.Matches(msg, q.keyMap.Close):
75− return CloseMsg{}
78+ return ActionClose{}
7679 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
7780 q.selectedNo = !q.selectedNo
7881 case key.Matches(msg, q.keyMap.EnterSpace):
7982 if !q.selectedNo {
80− return QuitMsg{}
83+ return ActionQuit{}
8184 }
82− return CloseMsg{}
85+ return ActionClose{}
8386 case key.Matches(msg, q.keyMap.Yes):
84− return QuitMsg{}
87+ return ActionQuit{}
8588 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
86− return CloseMsg{}
89+ return ActionClose{}
8790 }
8891 }
8992
9093 return nil
9194}
9295
93−// View implements [Dialog].
94−func (q *Quit) View() string {
96+// Draw implements [Dialog].
97+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
9598 const question = "Are you sure you want to quit?"
9699 baseStyle := q.com.Styles.Base
97100 buttonOpts := []common.ButtonOpts{
@@ -108,7 +111,9 @@ func (q *Quit) View() string {
108111 ),
109112 )
110113
111− return q.com.Styles.BorderFocus.Render(content)
114+ view := q.com.Styles.BorderFocus.Render(content)
115+ DrawCenter(scr, area, view)
116+ return nil
112117}
113118
114119// ShortHelp implements [help.KeyMap].
internal/ui/dialog/sessions.go+34−33
@@ -9,6 +9,8 @@ import (
99 tea "charm.land/bubbletea/v2"
1010 "github.com/charmbracelet/crush/internal/ui/common"
1111 "github.com/charmbracelet/crush/internal/ui/list"
12+ uv "github.com/charmbracelet/ultraviolet"
13+ "github.com/charmbracelet/x/ansi"
1214)
1315
1416// SessionsID is the identifier for the session selector dialog.
@@ -16,7 +18,6 @@ const SessionsID = "session"
1618
1719// Session is a session selector dialog.
1820type Session struct {
19− width, height int
2021 com *common.Common
2122 help help.Model
2223 list *list.FilterableList
@@ -56,6 +57,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
5657 s.help = help
5758 s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
5859 s.list.Focus()
60+ s.list.SetSelected(s.selectedSessionInx)
61+ s.list.ScrollToSelected()
5962
6063 s.input = textinput.New()
6164 s.input.SetVirtualCursor(false)
@@ -84,37 +87,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
8487 return s, nil
8588}
8689
87−// SetSize sets the size of the dialog.
88−func (s *Session) SetSize(width, height int) {
89− t := s.com.Styles
90− s.width = width
91− s.height = height
92− innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
93− heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
94− t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
95− t.Dialog.HelpView.GetVerticalFrameSize() +
96− t.Dialog.View.GetVerticalFrameSize()
97− s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
98− s.list.SetSize(innerWidth, height-heightOffset)
99− s.help.SetWidth(width)
100−
101− // Now that we know the height we can select the selected session and scroll to it.
102− s.list.SetSelected(s.selectedSessionInx)
103− s.list.ScrollToSelected()
104−}
105−
10690// ID implements Dialog.
10791func (s *Session) ID() string {
10892 return SessionsID
10993}
11094
111−// Update implements Dialog.
112−func (s *Session) Update(msg tea.Msg) tea.Msg {
95+// HandleMsg implements Dialog.
96+func (s *Session) HandleMsg(msg tea.Msg) Action {
11397 switch msg := msg.(type) {
11498 case tea.KeyPressMsg:
11599 switch {
116100 case key.Matches(msg, s.keyMap.Close):
117− return CloseMsg{}
101+ return ActionClose{}
118102 case key.Matches(msg, s.keyMap.Previous):
119103 s.list.Focus()
120104 if s.list.IsSelectedFirst() {
@@ -136,7 +120,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
136120 case key.Matches(msg, s.keyMap.Select):
137121 if item := s.list.SelectedItem(); item != nil {
138122 sessionItem := item.(*SessionItem)
139− return SessionSelectedMsg{sessionItem.Session}
123+ return ActionSelectSession{sessionItem.Session}
140124 }
141125 default:
142126 var cmd tea.Cmd
@@ -145,9 +129,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
145129 s.list.SetFilter(value)
146130 s.list.ScrollToTop()
147131 s.list.SetSelected(0)
148− if cmd != nil {
149− return cmd()
150− }
132+ return ActionCmd{cmd}
151133 }
152134 }
153135 return nil
@@ -158,16 +140,35 @@ func (s *Session) Cursor() *tea.Cursor {
158140 return InputCursor(s.com.Styles, s.input.Cursor())
159141}
160142
161−// View implements [Dialog].
162−func (s *Session) View() string {
143+// Draw implements [Dialog].
144+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
145+ t := s.com.Styles
146+ width := max(0, min(120, area.Dx()))
147+ height := max(0, min(30, area.Dy()))
148+ // TODO: Why do we need this 2?
149+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
150+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
151+ t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
152+ t.Dialog.HelpView.GetVerticalFrameSize() +
153+ // TODO: Why do we need this 2?
154+ t.Dialog.View.GetVerticalFrameSize() + 2
155+ s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
156+ s.list.SetSize(innerWidth, height-heightOffset)
157+ s.help.SetWidth(innerWidth)
158+
163159 titleStyle := s.com.Styles.Dialog.Title
164− dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
160+ dialogStyle := s.com.Styles.Dialog.View.Width(width)
165161 header := common.DialogTitle(s.com.Styles, "Switch Session",
166− max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
162+ max(0, width-dialogStyle.GetHorizontalFrameSize()-
167163 titleStyle.GetHorizontalFrameSize()))
168164
169− return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
170− s.input.View(), s.list.Render(), s.help.View(s))
165+ helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
166+ view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
167+ s.input.View(), s.list.Render(), helpView)
168+
169+ cur := s.Cursor()
170+ DrawCenterCursor(scr, area, view, cur)
171+ return cur
171172}
172173
173174// ShortHelp implements [help.KeyMap].
internal/ui/dialog/sessions_item.go+5−5
@@ -74,7 +74,7 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
7474 }
7575
7676 var infoText string
77− var infoLen int
77+ var infoWidth int
7878 lineWidth := width
7979 if len(info) > 0 {
8080 infoText = fmt.Sprintf(" %s ", info)
@@ -84,12 +84,12 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
8484 infoText = t.Subtle.Render(infoText)
8585 }
8686
87− infoLen = lipgloss.Width(infoText)
87+ infoWidth = lipgloss.Width(infoText)
8888 }
8989
90− title = ansi.Truncate(title, max(0, lineWidth), "")
91− titleLen := lipgloss.Width(title)
92− gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen))
90+ title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
91+ titleWidth := lipgloss.Width(title)
92+ gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
9393 content := title
9494 if matches := len(m.MatchedIndexes); matches > 0 {
9595 var lastPos int
internal/ui/model/ui.go+136−123
@@ -404,6 +404,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
404404 if m.completionsOpen {
405405 m.completions.SetFiles(msg.Files)
406406 }
407+ default:
408+ if m.dialog.HasDialogs() {
409+ if cmd := m.handleDialogMsg(msg); cmd != nil {
410+ cmds = append(cmds, cmd)
411+ }
412+ }
407413 }
408414
409415 // 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.
690696 return tea.Batch(cmds...)
691697}
692698
699+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
700+ var cmds []tea.Cmd
701+ action := m.dialog.Update(msg)
702+ if action == nil {
703+ return tea.Batch(cmds...)
704+ }
705+
706+ switch msg := action.(type) {
707+ // Generic dialog messages
708+ case dialog.ActionClose:
709+ m.dialog.CloseFrontDialog()
710+ if m.focus == uiFocusEditor {
711+ cmds = append(cmds, m.textarea.Focus())
712+ }
713+ case dialog.ActionCmd:
714+ if msg.Cmd != nil {
715+ cmds = append(cmds, msg.Cmd)
716+ }
717+
718+ // Session dialog messages
719+ case dialog.ActionSelectSession:
720+ m.dialog.CloseDialog(dialog.SessionsID)
721+ cmds = append(cmds, m.loadSession(msg.Session.ID))
722+
723+ // Open dialog message
724+ case dialog.ActionOpenDialog:
725+ m.dialog.CloseDialog(dialog.CommandsID)
726+ if cmd := m.openDialog(msg.DialogID); cmd != nil {
727+ cmds = append(cmds, cmd)
728+ }
729+
730+ // Command dialog messages
731+ case dialog.ActionToggleYoloMode:
732+ yolo := !m.com.App.Permissions.SkipRequests()
733+ m.com.App.Permissions.SetSkipRequests(yolo)
734+ m.setEditorPrompt(yolo)
735+ m.dialog.CloseDialog(dialog.CommandsID)
736+ case dialog.ActionNewSession:
737+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
738+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
739+ break
740+ }
741+ m.newSession()
742+ m.dialog.CloseDialog(dialog.CommandsID)
743+ case dialog.ActionSummarize:
744+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
745+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
746+ break
747+ }
748+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
749+ if err != nil {
750+ cmds = append(cmds, uiutil.ReportError(err))
751+ }
752+ case dialog.ActionToggleHelp:
753+ m.status.ToggleHelp()
754+ m.dialog.CloseDialog(dialog.CommandsID)
755+ case dialog.ActionQuit:
756+ cmds = append(cmds, tea.Quit)
757+ case dialog.ActionSelectModel:
758+ if m.com.App.AgentCoordinator.IsBusy() {
759+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
760+ break
761+ }
762+
763+ // TODO: Validate model API and authentication here?
764+
765+ cfg := m.com.Config()
766+ if cfg == nil {
767+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
768+ break
769+ }
770+
771+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
772+ cmds = append(cmds, uiutil.ReportError(err))
773+ }
774+
775+ // XXX: Should this be in a separate goroutine?
776+ go m.com.App.UpdateAgentModel(context.TODO())
777+
778+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
779+ cmds = append(cmds, uiutil.ReportInfo(modelMsg))
780+ m.dialog.CloseDialog(dialog.ModelsID)
781+ }
782+
783+ return tea.Batch(cmds...)
784+}
785+
693786func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
694787 var cmds []tea.Cmd
695788
@@ -729,96 +822,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
729822
730823 // Route all messages to dialog if one is open.
731824 if m.dialog.HasDialogs() {
732− msg := m.dialog.Update(msg)
733− if msg == nil {
734− return tea.Batch(cmds...)
735− }
736−
737− switch msg := msg.(type) {
738− // Generic dialog messages
739− case dialog.CloseMsg:
740− m.dialog.CloseFrontDialog()
741− if m.focus == uiFocusEditor {
742− cmds = append(cmds, m.textarea.Focus())
743− }
744−
745− // Session dialog messages
746− case dialog.SessionSelectedMsg:
747− m.dialog.CloseDialog(dialog.SessionsID)
748− cmds = append(cmds, m.loadSession(msg.Session.ID))
749−
750− // Open dialog message
751− case dialog.OpenDialogMsg:
752− switch msg.DialogID {
753− case dialog.SessionsID:
754− if cmd := m.openSessionsDialog(); cmd != nil {
755− cmds = append(cmds, cmd)
756− }
757− case dialog.ModelsID:
758− if cmd := m.openModelsDialog(); cmd != nil {
759− cmds = append(cmds, cmd)
760− }
761− default:
762− // Unknown dialog
763− break
764− }
765−
766− m.dialog.CloseDialog(dialog.CommandsID)
767−
768− // Command dialog messages
769− case dialog.ToggleYoloModeMsg:
770− yolo := !m.com.App.Permissions.SkipRequests()
771− m.com.App.Permissions.SetSkipRequests(yolo)
772− m.setEditorPrompt(yolo)
773− m.dialog.CloseDialog(dialog.CommandsID)
774− case dialog.NewSessionsMsg:
775− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
776− cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
777− break
778− }
779− m.newSession()
780− m.dialog.CloseDialog(dialog.CommandsID)
781− case dialog.CompactMsg:
782− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
783− cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
784− break
785− }
786− err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
787− if err != nil {
788− cmds = append(cmds, uiutil.ReportError(err))
789− }
790− case dialog.ToggleHelpMsg:
791− m.status.ToggleHelp()
792− m.dialog.CloseDialog(dialog.CommandsID)
793− case dialog.QuitMsg:
794− cmds = append(cmds, tea.Quit)
795− case dialog.ModelSelectedMsg:
796− if m.com.App.AgentCoordinator.IsBusy() {
797− cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
798− break
799− }
800−
801− // TODO: Validate model API and authentication here?
802−
803− cfg := m.com.Config()
804− if cfg == nil {
805− cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
806− break
807− }
808−
809− if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
810− cmds = append(cmds, uiutil.ReportError(err))
811− }
812−
813− // XXX: Should this be in a separate goroutine?
814− go m.com.App.UpdateAgentModel(context.TODO())
815−
816− modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
817− cmds = append(cmds, uiutil.ReportInfo(modelMsg))
818− m.dialog.CloseDialog(dialog.ModelsID)
819− }
820−
821− return tea.Batch(cmds...)
825+ return m.handleDialogMsg(msg)
822826 }
823827
824828 switch m.state {
@@ -1035,7 +1039,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
10351039}
10361040
10371041// Draw implements [uv.Drawable] and draws the UI model.
1038−func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1042+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
10391043 layout := m.generateLayout(area.Dx(), area.Dy())
10401044
10411045 if m.layout != layout {
@@ -1132,36 +1136,20 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
11321136 })
11331137 }
11341138
1135− // This needs to come last to overlay on top of everything
1139+ // This needs to come last to overlay on top of everything. We always pass
1140+ // the full screen bounds because the dialogs will position themselves
1141+ // accordingly.
11361142 if m.dialog.HasDialogs() {
1137− m.dialog.Draw(scr, area)
1143+ return m.dialog.Draw(scr, scr.Bounds())
11381144 }
1139−}
11401145
1141−// Cursor returns the cursor position and properties for the UI model. It
1142−// returns nil if the cursor should not be shown.
1143−func (m *UI) Cursor() *tea.Cursor {
1144− if m.layout.editor.Dy() <= 0 {
1145− // Don't show cursor if editor is not visible
1146− return nil
1147− }
1148− if m.dialog.HasDialogs() {
1149− if front := m.dialog.DialogLast(); front != nil {
1150− c, ok := front.(uiutil.Cursor)
1151− if ok {
1152− cur := c.Cursor()
1153− if cur != nil {
1154− pos := m.dialog.CenterPosition(m.layout.area, front.ID())
1155− cur.X += pos.Min.X
1156− cur.Y += pos.Min.Y
1157− return cur
1158− }
1159− }
1160− }
1161− return nil
1162− }
11631146 switch m.focus {
11641147 case uiFocusEditor:
1148+ if m.layout.editor.Dy() <= 0 {
1149+ // Don't show cursor if editor is not visible
1150+ return nil
1151+ }
1152+
11651153 if m.textarea.Focused() {
11661154 cur := m.textarea.Cursor()
11671155 cur.X++ // Adjust for app margins
@@ -1181,11 +1169,10 @@ func (m *UI) View() tea.View {
11811169 var v tea.View
11821170 v.AltScreen = true
11831171 v.BackgroundColor = m.com.Styles.Background
1184− v.Cursor = m.Cursor()
11851172 v.MouseMode = tea.MouseModeCellMotion
11861173
11871174 canvas := uv.NewScreenBuffer(m.width, m.height)
1188− m.Draw(canvas, canvas.Bounds())
1175+ v.Cursor = m.Draw(canvas, canvas.Bounds())
11891176
11901177 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
11911178 contentLines := strings.Split(content, "\n")
@@ -1813,6 +1800,33 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
18131800 return tea.Batch(cmds...)
18141801}
18151802
1803+// openDialog opens a dialog by its ID.
1804+func (m *UI) openDialog(id string) tea.Cmd {
1805+ var cmds []tea.Cmd
1806+ switch id {
1807+ case dialog.SessionsID:
1808+ if cmd := m.openSessionsDialog(); cmd != nil {
1809+ cmds = append(cmds, cmd)
1810+ }
1811+ case dialog.ModelsID:
1812+ if cmd := m.openModelsDialog(); cmd != nil {
1813+ cmds = append(cmds, cmd)
1814+ }
1815+ case dialog.CommandsID:
1816+ if cmd := m.openCommandsDialog(); cmd != nil {
1817+ cmds = append(cmds, cmd)
1818+ }
1819+ case dialog.QuitID:
1820+ if cmd := m.openQuitDialog(); cmd != nil {
1821+ cmds = append(cmds, cmd)
1822+ }
1823+ default:
1824+ // Unknown dialog
1825+ break
1826+ }
1827+ return tea.Batch(cmds...)
1828+}
1829+
18161830// openQuitDialog opens the quit confirmation dialog.
18171831func (m *UI) openQuitDialog() tea.Cmd {
18181832 if m.dialog.ContainsDialog(dialog.QuitID) {
@@ -1839,7 +1853,6 @@ func (m *UI) openModelsDialog() tea.Cmd {
18391853 return uiutil.ReportError(err)
18401854 }
18411855
1842− modelsDialog.SetSize(min(60, m.width-8), 30)
18431856 m.dialog.OpenDialog(modelsDialog)
18441857
18451858 return nil
@@ -1863,8 +1876,6 @@ func (m *UI) openCommandsDialog() tea.Cmd {
18631876 return uiutil.ReportError(err)
18641877 }
18651878
1866− // TODO: Get. Rid. Of. Magic numbers!
1867− commands.SetSize(min(120, m.width-8), 30)
18681879 m.dialog.OpenDialog(commands)
18691880
18701881 return nil
@@ -1890,8 +1901,6 @@ func (m *UI) openSessionsDialog() tea.Cmd {
18901901 return uiutil.ReportError(err)
18911902 }
18921903
1893− // TODO: Get. Rid. Of. Magic numbers!
1894− dialog.SetSize(min(120, m.width-8), 30)
18951904 m.dialog.OpenDialog(dialog)
18961905
18971906 return nil
@@ -1915,6 +1924,10 @@ func (m *UI) newSession() {
19151924
19161925// handlePasteMsg handles a paste message.
19171926func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1927+ if m.dialog.HasDialogs() {
1928+ return m.handleDialogMsg(msg)
1929+ }
1930+
19181931 if m.focus != uiFocusEditor {
19191932 return nil
19201933 }