on main help sign in
refactor(ui): dialog: message and draw handling (#1822)
fcd1b83Ayman Bagabas
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 (
21 "github.com/charmbracelet/crush/internal/ui/styles"
21 "github.com/charmbracelet/crush/internal/ui/styles"
22 "github.com/charmbracelet/crush/internal/uicmd"
22 "github.com/charmbracelet/crush/internal/uicmd"
23 "github.com/charmbracelet/crush/internal/uiutil"
23 "github.com/charmbracelet/crush/internal/uiutil"
24+ uv "github.com/charmbracelet/ultraviolet"
25+ "github.com/charmbracelet/x/ansi"
24)
26)
25
27
26// CommandsID is the identifier for the commands dialog.
28// CommandsID is the identifier for the commands dialog.
@@ -43,10 +45,11 @@ type Commands struct {
43 userCmds []uicmd.Command
45 userCmds []uicmd.Command
44 mcpPrompts *csync.Slice[uicmd.Command]
46 mcpPrompts *csync.Slice[uicmd.Command]
45
47
46− help help.Model
48+ help help.Model
47− input textinput.Model
49+ input textinput.Model
48− list *list.FilterableList
50+ list *list.FilterableList
51+
49− width, height int
52+ width int
50}
53}
51
54
52var _ Dialog = (*Commands)(nil)
55var _ Dialog = (*Commands)(nil)
@@ -114,33 +117,18 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
114 return c, nil
117 return c, nil
115}
118}
116
119
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−
132// ID implements Dialog.
120// ID implements Dialog.
133func (c *Commands) ID() string {
121func (c *Commands) ID() string {
134 return CommandsID
122 return CommandsID
135}
123}
136
124
137−// Update implements Dialog.
125+// HandleMsg implements Dialog.
138−func (c *Commands) Update(msg tea.Msg) tea.Msg {
126+func (c *Commands) HandleMsg(msg tea.Msg) Action {
139 switch msg := msg.(type) {
127 switch msg := msg.(type) {
140 case tea.KeyPressMsg:
128 case tea.KeyPressMsg:
141 switch {
129 switch {
142 case key.Matches(msg, c.keyMap.Close):
130 case key.Matches(msg, c.keyMap.Close):
143− return CloseMsg{}
131+ return ActionClose{}
144 case key.Matches(msg, c.keyMap.Previous):
132 case key.Matches(msg, c.keyMap.Previous):
145 c.list.Focus()
133 c.list.Focus()
146 if c.list.IsSelectedFirst() {
134 if c.list.IsSelectedFirst() {
@@ -181,9 +169,7 @@ func (c *Commands) Update(msg tea.Msg) tea.Msg {
181 c.list.SetFilter(value)
169 c.list.SetFilter(value)
182 c.list.ScrollToTop()
170 c.list.ScrollToTop()
183 c.list.SetSelected(0)
171 c.list.SetSelected(0)
184− if cmd != nil {
172+ return ActionCmd{cmd}
185− return cmd()
186− }
187 }
173 }
188 }
174 }
189 return nil
175 return nil
@@ -231,16 +217,35 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
231 return strings.Join(parts, " ")
217 return strings.Join(parts, " ")
232}
218}
233
219
234−// View implements [Dialog].
220+// Draw implements [Dialog].
235−func (c *Commands) View() string {
221+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
236 t := c.com.Styles
222 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+
237 radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
237 radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
238 titleStyle := t.Dialog.Title
238 titleStyle := t.Dialog.Title
239− dialogStyle := t.Dialog.View.Width(c.width)
239+ dialogStyle := t.Dialog.View.Width(width)
240 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
240 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
241+ helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
241− header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
242+ header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
242− return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
243+ view := HeaderInputListHelpView(t, width, c.list.Height(), header,
243− c.input.View(), c.list.Render(), c.help.View(c))
244+ c.input.View(), c.list.Render(), helpView)
245+
246+ cur := c.Cursor()
247+ DrawCenterCursor(scr, area, view, cur)
248+ return cur
244}
249}
245
250
246// ShortHelp implements [help.KeyMap].
251// ShortHelp implements [help.KeyMap].
@@ -318,7 +323,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
318 Description: "start a new session",
323 Description: "start a new session",
319 Shortcut: "ctrl+n",
324 Shortcut: "ctrl+n",
320 Handler: func(cmd uicmd.Command) tea.Cmd {
325 Handler: func(cmd uicmd.Command) tea.Cmd {
321− return uiutil.CmdHandler(NewSessionsMsg{})
326+ return uiutil.CmdHandler(ActionNewSession{})
322 },
327 },
323 },
328 },
324 {
329 {
@@ -327,7 +332,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
327 Description: "Switch to a different session",
332 Description: "Switch to a different session",
328 Shortcut: "ctrl+s",
333 Shortcut: "ctrl+s",
329 Handler: func(cmd uicmd.Command) tea.Cmd {
334 Handler: func(cmd uicmd.Command) tea.Cmd {
330− return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
335+ return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
331 },
336 },
332 },
337 },
333 {
338 {
@@ -337,7 +342,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
337 // FIXME: The shortcut might get updated if enhanced keyboard is supported.
342 // FIXME: The shortcut might get updated if enhanced keyboard is supported.
338 Shortcut: "ctrl+l",
343 Shortcut: "ctrl+l",
339 Handler: func(cmd uicmd.Command) tea.Cmd {
344 Handler: func(cmd uicmd.Command) tea.Cmd {
340− return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
345+ return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
341 },
346 },
342 },
347 },
343 }
348 }
@@ -349,7 +354,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
349 Title: "Summarize Session",
354 Title: "Summarize Session",
350 Description: "Summarize the current session and create a new one with the summary",
355 Description: "Summarize the current session and create a new one with the summary",
351 Handler: func(cmd uicmd.Command) tea.Cmd {
356 Handler: func(cmd uicmd.Command) tea.Cmd {
352− return uiutil.CmdHandler(CompactMsg{
357+ return uiutil.CmdHandler(ActionSummarize{
353 SessionID: c.sessionID,
358 SessionID: c.sessionID,
354 })
359 })
355 },
360 },
@@ -375,7 +380,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
375 Title: status + " Thinking Mode",
380 Title: status + " Thinking Mode",
376 Description: "Toggle model thinking for reasoning-capable models",
381 Description: "Toggle model thinking for reasoning-capable models",
377 Handler: func(cmd uicmd.Command) tea.Cmd {
382 Handler: func(cmd uicmd.Command) tea.Cmd {
378− return uiutil.CmdHandler(ToggleThinkingMsg{})
383+ return uiutil.CmdHandler(ActionToggleThinking{})
379 },
384 },
380 })
385 })
381 }
386 }
@@ -387,7 +392,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
387 Title: "Select Reasoning Effort",
392 Title: "Select Reasoning Effort",
388 Description: "Choose reasoning effort level (low/medium/high)",
393 Description: "Choose reasoning effort level (low/medium/high)",
389 Handler: func(cmd uicmd.Command) tea.Cmd {
394 Handler: func(cmd uicmd.Command) tea.Cmd {
390− return uiutil.CmdHandler(OpenReasoningDialogMsg{})
395+ return uiutil.CmdHandler(ActionOpenDialog{
396+ // TODO: Pass reasoning dialog id
397+ })
391 },
398 },
392 })
399 })
393 }
400 }
@@ -401,7 +408,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
401 Title: "Toggle Sidebar",
408 Title: "Toggle Sidebar",
402 Description: "Toggle between compact and normal layout",
409 Description: "Toggle between compact and normal layout",
403 Handler: func(cmd uicmd.Command) tea.Cmd {
410 Handler: func(cmd uicmd.Command) tea.Cmd {
404− return uiutil.CmdHandler(ToggleCompactModeMsg{})
411+ return uiutil.CmdHandler(ActionToggleCompactMode{})
405 },
412 },
406 })
413 })
407 }
414 }
@@ -416,7 +423,9 @@ func (c *Commands) defaultCommands() []uicmd.Command {
416 Shortcut: "ctrl+f",
423 Shortcut: "ctrl+f",
417 Description: "Open file picker",
424 Description: "Open file picker",
418 Handler: func(cmd uicmd.Command) tea.Cmd {
425 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+ })
420 },
429 },
421 })
430 })
422 }
431 }
@@ -431,7 +440,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
431 Shortcut: "ctrl+o",
440 Shortcut: "ctrl+o",
432 Description: "Open external editor to compose message",
441 Description: "Open external editor to compose message",
433 Handler: func(cmd uicmd.Command) tea.Cmd {
442 Handler: func(cmd uicmd.Command) tea.Cmd {
434− return uiutil.CmdHandler(OpenExternalEditorMsg{})
443+ return uiutil.CmdHandler(ActionExternalEditor{})
435 },
444 },
436 })
445 })
437 }
446 }
@@ -442,7 +451,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
442 Title: "Toggle Yolo Mode",
451 Title: "Toggle Yolo Mode",
443 Description: "Toggle yolo mode",
452 Description: "Toggle yolo mode",
444 Handler: func(cmd uicmd.Command) tea.Cmd {
453 Handler: func(cmd uicmd.Command) tea.Cmd {
445− return uiutil.CmdHandler(ToggleYoloModeMsg{})
454+ return uiutil.CmdHandler(ActionToggleYoloMode{})
446 },
455 },
447 },
456 },
448 {
457 {
@@ -451,7 +460,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
451 Shortcut: "ctrl+g",
460 Shortcut: "ctrl+g",
452 Description: "Toggle help",
461 Description: "Toggle help",
453 Handler: func(cmd uicmd.Command) tea.Cmd {
462 Handler: func(cmd uicmd.Command) tea.Cmd {
454− return uiutil.CmdHandler(ToggleHelpMsg{})
463+ return uiutil.CmdHandler(ActionToggleHelp{})
455 },
464 },
456 },
465 },
457 {
466 {
internal/ui/dialog/dialog.go+28−19
@@ -14,11 +14,19 @@ var CloseKey = key.NewBinding(
14 key.WithHelp("esc", "exit"),
14 key.WithHelp("esc", "exit"),
15)
15)
16
16
17+// Action represents an action taken in a dialog after handling a message.
18+type Action interface{}
19+
17// Dialog is a component that can be displayed on top of the UI.
20// Dialog is a component that can be displayed on top of the UI.
18type Dialog interface {
21type Dialog interface {
22+ // ID returns the unique identifier of the dialog.
19 ID() string
23 ID() 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.
20− Update(msg tea.Msg) tea.Msg
26+ HandleMsg(msg tea.Msg) Action
21− View() string
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
22}
30}
23
31
24// Overlay manages multiple dialogs as an overlay.
32// Overlay manages multiple dialogs as an overlay.
@@ -113,33 +121,34 @@ func (d *Overlay) Update(msg tea.Msg) tea.Msg {
113 return nil
121 return nil
114 }
122 }
115
123
116− return dialog.Update(msg)
124+ return dialog.HandleMsg(msg)
117}
125}
118
126
119−// CenterPosition calculates the centered position for the dialog.
127+// DrawCenterCursor draws the given string view centered in the screen area and
120−func (d *Overlay) CenterPosition(area uv.Rectangle, dialogID string) uv.Rectangle {
128+// adjusts the cursor position accordingly.
121− dialog := d.Dialog(dialogID)
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)
122− if dialog == nil {
132+ if cur != nil {
123− return uv.Rectangle{}
133+ cur.X += center.Min.X
134+ cur.Y += center.Min.Y
124 }
135 }
125− return d.centerPositionView(area, dialog.View())
136+
137+ uv.NewStyledString(view).Draw(scr, center)
126}
138}
127
139
140+// DrawCenter draws the given string view centered in the screen area.
128−func (d *Overlay) centerPositionView(area uv.Rectangle, view string) uv.Rectangle {
141+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
129− viewWidth := lipgloss.Width(view)
142+ DrawCenterCursor(scr, area, view, nil)
130− viewHeight := lipgloss.Height(view)
131− return common.CenterRect(area, viewWidth, viewHeight)
132}
143}
133
144
134// Draw renders the overlay and its dialogs.
145// 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
136 for _, dialog := range d.dialogs {
148 for _, dialog := range d.dialogs {
137− view := dialog.View()
149+ cur = dialog.Draw(scr, area)
138− center := d.centerPositionView(area, view)
139− if area.Overlaps(center) {
140− uv.NewStyledString(view).Draw(scr, center)
141− }
142 }
150 }
151+ return cur
143}
152}
144
153
145// removeDialog removes a dialog from the stack.
154// 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 (
15 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/uiutil"
17 "github.com/charmbracelet/crush/internal/uiutil"
18+ uv "github.com/charmbracelet/ultraviolet"
19+ "github.com/charmbracelet/x/ansi"
18)
20)
19
21
20// ModelType represents the type of model to select.
22// ModelType represents the type of model to select.
@@ -76,8 +78,6 @@ type Models struct {
76 modelType ModelType
78 modelType ModelType
77 providers []catwalk.Provider
79 providers []catwalk.Provider
78
80
79− width, height int
80−
81 keyMap struct {
81 keyMap struct {
82 Tab key.Binding
82 Tab key.Binding
83 UpDown key.Binding
83 UpDown key.Binding
@@ -147,33 +147,18 @@ func NewModels(com *common.Common) (*Models, error) {
147 return m, nil
147 return m, nil
148}
148}
149
149
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−
165// ID implements Dialog.
150// ID implements Dialog.
166func (m *Models) ID() string {
151func (m *Models) ID() string {
167 return ModelsID
152 return ModelsID
168}
153}
169
154
170−// Update implements Dialog.
155+// HandleMsg implements Dialog.
171−func (m *Models) Update(msg tea.Msg) tea.Msg {
156+func (m *Models) HandleMsg(msg tea.Msg) Action {
172 switch msg := msg.(type) {
157 switch msg := msg.(type) {
173 case tea.KeyPressMsg:
158 case tea.KeyPressMsg:
174 switch {
159 switch {
175 case key.Matches(msg, m.keyMap.Close):
160 case key.Matches(msg, m.keyMap.Close):
176− return CloseMsg{}
161+ return ActionClose{}
177 case key.Matches(msg, m.keyMap.Previous):
162 case key.Matches(msg, m.keyMap.Previous):
178 m.list.Focus()
163 m.list.Focus()
179 if m.list.IsSelectedFirst() {
164 if m.list.IsSelectedFirst() {
@@ -203,7 +188,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
203 break
188 break
204 }
189 }
205
190
206− return ModelSelectedMsg{
191+ return ActionSelectModel{
207 Model: modelItem.SelectedModel(),
192 Model: modelItem.SelectedModel(),
208 ModelType: modelItem.SelectedModelType(),
193 ModelType: modelItem.SelectedModelType(),
209 }
194 }
@@ -222,9 +207,7 @@ func (m *Models) Update(msg tea.Msg) tea.Msg {
222 value := m.input.Value()
207 value := m.input.Value()
223 m.list.SetFilter(value)
208 m.list.SetFilter(value)
224 m.list.ScrollToSelected()
209 m.list.ScrollToSelected()
225− if cmd != nil {
210+ return ActionCmd{cmd}
226− return cmd()
227− }
228 }
211 }
229 }
212 }
230 return nil
213 return nil
@@ -255,9 +238,22 @@ func (m *Models) modelTypeRadioView() string {
255 smallRadio, textStyle.Render(ModelTypeSmall.String()))
238 smallRadio, textStyle.Render(ModelTypeSmall.String()))
256}
239}
257
240
258−// View implements Dialog.
241+// Draw implements [Dialog].
259−func (m *Models) View() string {
242+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
260 t := m.com.Styles
243 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+
261 titleStyle := t.Dialog.Title
257 titleStyle := t.Dialog.Title
262 dialogStyle := t.Dialog.View
258 dialogStyle := t.Dialog.View
263
259
@@ -266,10 +262,15 @@ func (m *Models) View() string {
266 headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
262 headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
267 dialogStyle.GetHorizontalFrameSize()
263 dialogStyle.GetHorizontalFrameSize()
268
264
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)
270
270
271− return HeaderInputListHelpView(t, m.width, m.list.Height(), header,
271+ cur := m.Cursor()
272− m.input.View(), m.list.Render(), m.help.View(m))
272+ DrawCenterCursor(scr, area, view, cur)
273+ return cur
273}
274}
274
275
275// ShortHelp returns the short help view.
276// ShortHelp returns the short help view.
internal/ui/dialog/quit.go+16−11
@@ -5,6 +5,7 @@ import (
5 tea "charm.land/bubbletea/v2"
5 tea "charm.land/bubbletea/v2"
6 "charm.land/lipgloss/v2"
6 "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/crush/internal/ui/common"
8+ uv "github.com/charmbracelet/ultraviolet"
8)
9)
9
10
10// QuitID is the identifier for the quit dialog.
11// QuitID is the identifier for the quit dialog.
@@ -25,6 +26,8 @@ type Quit struct {
25 }
26 }
26}
27}
27
28
29+var _ Dialog = (*Quit)(nil)
30+
28// NewQuit creates a new quit confirmation dialog.
31// NewQuit creates a new quit confirmation dialog.
29func NewQuit(com *common.Common) *Quit {
32func NewQuit(com *common.Common) *Quit {
30 q := &Quit{
33 q := &Quit{
@@ -64,34 +67,34 @@ func (*Quit) ID() string {
64 return QuitID
67 return QuitID
65}
68}
66
69
67−// Update implements [Model].
70+// HandleMsg implements [Model].
68−func (q *Quit) Update(msg tea.Msg) tea.Msg {
71+func (q *Quit) HandleMsg(msg tea.Msg) Action {
69 switch msg := msg.(type) {
72 switch msg := msg.(type) {
70 case tea.KeyPressMsg:
73 case tea.KeyPressMsg:
71 switch {
74 switch {
72 case key.Matches(msg, q.keyMap.Quit):
75 case key.Matches(msg, q.keyMap.Quit):
73− return QuitMsg{}
76+ return ActionQuit{}
74 case key.Matches(msg, q.keyMap.Close):
77 case key.Matches(msg, q.keyMap.Close):
75− return CloseMsg{}
78+ return ActionClose{}
76 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
79 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
77 q.selectedNo = !q.selectedNo
80 q.selectedNo = !q.selectedNo
78 case key.Matches(msg, q.keyMap.EnterSpace):
81 case key.Matches(msg, q.keyMap.EnterSpace):
79 if !q.selectedNo {
82 if !q.selectedNo {
80− return QuitMsg{}
83+ return ActionQuit{}
81 }
84 }
82− return CloseMsg{}
85+ return ActionClose{}
83 case key.Matches(msg, q.keyMap.Yes):
86 case key.Matches(msg, q.keyMap.Yes):
84− return QuitMsg{}
87+ return ActionQuit{}
85 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
88 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
86− return CloseMsg{}
89+ return ActionClose{}
87 }
90 }
88 }
91 }
89
92
90 return nil
93 return nil
91}
94}
92
95
93−// View implements [Dialog].
96+// Draw implements [Dialog].
94−func (q *Quit) View() string {
97+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
95 const question = "Are you sure you want to quit?"
98 const question = "Are you sure you want to quit?"
96 baseStyle := q.com.Styles.Base
99 baseStyle := q.com.Styles.Base
97 buttonOpts := []common.ButtonOpts{
100 buttonOpts := []common.ButtonOpts{
@@ -108,7 +111,9 @@ func (q *Quit) View() string {
108 ),
111 ),
109 )
112 )
110
113
111− return q.com.Styles.BorderFocus.Render(content)
114+ view := q.com.Styles.BorderFocus.Render(content)
115+ DrawCenter(scr, area, view)
116+ return nil
112}
117}
113
118
114// ShortHelp implements [help.KeyMap].
119// ShortHelp implements [help.KeyMap].
internal/ui/dialog/sessions.go+34−33
@@ -9,6 +9,8 @@ import (
9 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/list"
11 "github.com/charmbracelet/crush/internal/ui/list"
12+ uv "github.com/charmbracelet/ultraviolet"
13+ "github.com/charmbracelet/x/ansi"
12)
14)
13
15
14// SessionsID is the identifier for the session selector dialog.
16// SessionsID is the identifier for the session selector dialog.
@@ -16,7 +18,6 @@ const SessionsID = "session"
16
18
17// Session is a session selector dialog.
19// Session is a session selector dialog.
18type Session struct {
20type Session struct {
19− width, height int
20 com *common.Common
21 com *common.Common
21 help help.Model
22 help help.Model
22 list *list.FilterableList
23 list *list.FilterableList
@@ -56,6 +57,8 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
56 s.help = help
57 s.help = help
57 s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
58 s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
58 s.list.Focus()
59 s.list.Focus()
60+ s.list.SetSelected(s.selectedSessionInx)
61+ s.list.ScrollToSelected()
59
62
60 s.input = textinput.New()
63 s.input = textinput.New()
61 s.input.SetVirtualCursor(false)
64 s.input.SetVirtualCursor(false)
@@ -84,37 +87,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
84 return s, nil
87 return s, nil
85}
88}
86
89
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−
106// ID implements Dialog.
90// ID implements Dialog.
107func (s *Session) ID() string {
91func (s *Session) ID() string {
108 return SessionsID
92 return SessionsID
109}
93}
110
94
111−// Update implements Dialog.
95+// HandleMsg implements Dialog.
112−func (s *Session) Update(msg tea.Msg) tea.Msg {
96+func (s *Session) HandleMsg(msg tea.Msg) Action {
113 switch msg := msg.(type) {
97 switch msg := msg.(type) {
114 case tea.KeyPressMsg:
98 case tea.KeyPressMsg:
115 switch {
99 switch {
116 case key.Matches(msg, s.keyMap.Close):
100 case key.Matches(msg, s.keyMap.Close):
117− return CloseMsg{}
101+ return ActionClose{}
118 case key.Matches(msg, s.keyMap.Previous):
102 case key.Matches(msg, s.keyMap.Previous):
119 s.list.Focus()
103 s.list.Focus()
120 if s.list.IsSelectedFirst() {
104 if s.list.IsSelectedFirst() {
@@ -136,7 +120,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
136 case key.Matches(msg, s.keyMap.Select):
120 case key.Matches(msg, s.keyMap.Select):
137 if item := s.list.SelectedItem(); item != nil {
121 if item := s.list.SelectedItem(); item != nil {
138 sessionItem := item.(*SessionItem)
122 sessionItem := item.(*SessionItem)
139− return SessionSelectedMsg{sessionItem.Session}
123+ return ActionSelectSession{sessionItem.Session}
140 }
124 }
141 default:
125 default:
142 var cmd tea.Cmd
126 var cmd tea.Cmd
@@ -145,9 +129,7 @@ func (s *Session) Update(msg tea.Msg) tea.Msg {
145 s.list.SetFilter(value)
129 s.list.SetFilter(value)
146 s.list.ScrollToTop()
130 s.list.ScrollToTop()
147 s.list.SetSelected(0)
131 s.list.SetSelected(0)
148− if cmd != nil {
132+ return ActionCmd{cmd}
149− return cmd()
150− }
151 }
133 }
152 }
134 }
153 return nil
135 return nil
@@ -158,16 +140,35 @@ func (s *Session) Cursor() *tea.Cursor {
158 return InputCursor(s.com.Styles, s.input.Cursor())
140 return InputCursor(s.com.Styles, s.input.Cursor())
159}
141}
160
142
161−// View implements [Dialog].
143+// Draw implements [Dialog].
162−func (s *Session) View() string {
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+
163 titleStyle := s.com.Styles.Dialog.Title
159 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)
165 header := common.DialogTitle(s.com.Styles, "Switch Session",
161 header := common.DialogTitle(s.com.Styles, "Switch Session",
166− max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
162+ max(0, width-dialogStyle.GetHorizontalFrameSize()-
167 titleStyle.GetHorizontalFrameSize()))
163 titleStyle.GetHorizontalFrameSize()))
168
164
165+ helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
169− return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
166+ view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
170− s.input.View(), s.list.Render(), s.help.View(s))
167+ s.input.View(), s.list.Render(), helpView)
168+
169+ cur := s.Cursor()
170+ DrawCenterCursor(scr, area, view, cur)
171+ return cur
171}
172}
172
173
173// ShortHelp implements [help.KeyMap].
174// 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
74 }
74 }
75
75
76 var infoText string
76 var infoText string
77− var infoLen int
77+ var infoWidth int
78 lineWidth := width
78 lineWidth := width
79 if len(info) > 0 {
79 if len(info) > 0 {
80 infoText = fmt.Sprintf(" %s ", info)
80 infoText = fmt.Sprintf(" %s ", info)
@@ -84,12 +84,12 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width
84 infoText = t.Subtle.Render(infoText)
84 infoText = t.Subtle.Render(infoText)
85 }
85 }
86
86
87− infoLen = lipgloss.Width(infoText)
87+ infoWidth = lipgloss.Width(infoText)
88 }
88 }
89
89
90− title = ansi.Truncate(title, max(0, lineWidth), "")
90+ title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
91− titleLen := lipgloss.Width(title)
91+ titleWidth := lipgloss.Width(title)
92− gap := strings.Repeat(" ", max(0, lineWidth-titleLen-infoLen))
92+ gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
93 content := title
93 content := title
94 if matches := len(m.MatchedIndexes); matches > 0 {
94 if matches := len(m.MatchedIndexes); matches > 0 {
95 var lastPos int
95 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) {
404 if m.completionsOpen {
404 if m.completionsOpen {
405 m.completions.SetFiles(msg.Files)
405 m.completions.SetFiles(msg.Files)
406 }
406 }
407+ default:
408+ if m.dialog.HasDialogs() {
409+ if cmd := m.handleDialogMsg(msg); cmd != nil {
410+ cmds = append(cmds, cmd)
411+ }
412+ }
407 }
413 }
408
414
409 // This logic gets triggered on any message type, but should it?
415 // 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.
690 return tea.Batch(cmds...)
696 return tea.Batch(cmds...)
691}
697}
692
698
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+
693func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
786func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
694 var cmds []tea.Cmd
787 var cmds []tea.Cmd
695
788
@@ -729,96 +822,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
729
822
730 // Route all messages to dialog if one is open.
823 // Route all messages to dialog if one is open.
731 if m.dialog.HasDialogs() {
824 if m.dialog.HasDialogs() {
732− msg := m.dialog.Update(msg)
825+ return m.handleDialogMsg(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...)
822 }
826 }
823
827
824 switch m.state {
828 switch m.state {
@@ -1035,7 +1039,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1035}
1039}
1036
1040
1037// Draw implements [uv.Drawable] and draws the UI model.
1041// 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 {
1039 layout := m.generateLayout(area.Dx(), area.Dy())
1043 layout := m.generateLayout(area.Dx(), area.Dy())
1040
1044
1041 if m.layout != layout {
1045 if m.layout != layout {
@@ -1132,36 +1136,20 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1132 })
1136 })
1133 }
1137 }
1134
1138
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.
1136 if m.dialog.HasDialogs() {
1142 if m.dialog.HasDialogs() {
1137− m.dialog.Draw(scr, area)
1143+ return m.dialog.Draw(scr, scr.Bounds())
1138 }
1144 }
1139−}
1140
1145
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− }
1163 switch m.focus {
1146 switch m.focus {
1164 case uiFocusEditor:
1147 case uiFocusEditor:
1148+ if m.layout.editor.Dy() <= 0 {
1149+ // Don't show cursor if editor is not visible
1150+ return nil
1151+ }
1152+
1165 if m.textarea.Focused() {
1153 if m.textarea.Focused() {
1166 cur := m.textarea.Cursor()
1154 cur := m.textarea.Cursor()
1167 cur.X++ // Adjust for app margins
1155 cur.X++ // Adjust for app margins
@@ -1181,11 +1169,10 @@ func (m *UI) View() tea.View {
1181 var v tea.View
1169 var v tea.View
1182 v.AltScreen = true
1170 v.AltScreen = true
1183 v.BackgroundColor = m.com.Styles.Background
1171 v.BackgroundColor = m.com.Styles.Background
1184− v.Cursor = m.Cursor()
1185 v.MouseMode = tea.MouseModeCellMotion
1172 v.MouseMode = tea.MouseModeCellMotion
1186
1173
1187 canvas := uv.NewScreenBuffer(m.width, m.height)
1174 canvas := uv.NewScreenBuffer(m.width, m.height)
1188− m.Draw(canvas, canvas.Bounds())
1175+ v.Cursor = m.Draw(canvas, canvas.Bounds())
1189
1176
1190 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1177 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1191 contentLines := strings.Split(content, "\n")
1178 contentLines := strings.Split(content, "\n")
@@ -1813,6 +1800,33 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
1813 return tea.Batch(cmds...)
1800 return tea.Batch(cmds...)
1814}
1801}
1815
1802
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+
1816// openQuitDialog opens the quit confirmation dialog.
1830// openQuitDialog opens the quit confirmation dialog.
1817func (m *UI) openQuitDialog() tea.Cmd {
1831func (m *UI) openQuitDialog() tea.Cmd {
1818 if m.dialog.ContainsDialog(dialog.QuitID) {
1832 if m.dialog.ContainsDialog(dialog.QuitID) {
@@ -1839,7 +1853,6 @@ func (m *UI) openModelsDialog() tea.Cmd {
1839 return uiutil.ReportError(err)
1853 return uiutil.ReportError(err)
1840 }
1854 }
1841
1855
1842− modelsDialog.SetSize(min(60, m.width-8), 30)
1843 m.dialog.OpenDialog(modelsDialog)
1856 m.dialog.OpenDialog(modelsDialog)
1844
1857
1845 return nil
1858 return nil
@@ -1863,8 +1876,6 @@ func (m *UI) openCommandsDialog() tea.Cmd {
1863 return uiutil.ReportError(err)
1876 return uiutil.ReportError(err)
1864 }
1877 }
1865
1878
1866− // TODO: Get. Rid. Of. Magic numbers!
1867− commands.SetSize(min(120, m.width-8), 30)
1868 m.dialog.OpenDialog(commands)
1879 m.dialog.OpenDialog(commands)
1869
1880
1870 return nil
1881 return nil
@@ -1890,8 +1901,6 @@ func (m *UI) openSessionsDialog() tea.Cmd {
1890 return uiutil.ReportError(err)
1901 return uiutil.ReportError(err)
1891 }
1902 }
1892
1903
1893− // TODO: Get. Rid. Of. Magic numbers!
1894− dialog.SetSize(min(120, m.width-8), 30)
1895 m.dialog.OpenDialog(dialog)
1904 m.dialog.OpenDialog(dialog)
1896
1905
1897 return nil
1906 return nil
@@ -1915,6 +1924,10 @@ func (m *UI) newSession() {
1915
1924
1916// handlePasteMsg handles a paste message.
1925// handlePasteMsg handles a paste message.
1917func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1926func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1927+ if m.dialog.HasDialogs() {
1928+ return m.handleDialogMsg(msg)
1929+ }
1930+
1918 if m.focus != uiFocusEditor {
1931 if m.focus != uiFocusEditor {
1919 return nil
1932 return nil
1920 }
1933 }