on main help sign in
refactor(ui): dialog: improve command and session dialogs
commit7fad242ae375f9d8a7b2b6803cfea700b5e558ce authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 16 Dec 2025, 12:51 (1 year ago) parent7c91f4c
8 files changed+291−195
internal/ui/dialog/commands.go+30−57 internal/ui/dialog/common.go+56 internal/ui/dialog/dialog.go+36−18 internal/ui/dialog/messages.go+34 internal/ui/dialog/quit.go+8−6 internal/ui/dialog/sessions.go+18−48 internal/ui/model/ui.go+104−65 internal/ui/styles/styles.go+5−1
internal/ui/dialog/commands.go+30−57
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/message"
1919 "github.com/charmbracelet/crush/internal/ui/common"
2020 "github.com/charmbracelet/crush/internal/ui/list"
21+ "github.com/charmbracelet/crush/internal/ui/styles"
2122 "github.com/charmbracelet/crush/internal/uicmd"
2223 "github.com/charmbracelet/crush/internal/uiutil"
2324)
@@ -32,24 +33,6 @@ type SendMsg struct {
3233 Attachments []message.Attachment
3334}
3435
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−
5336// Commands represents a dialog that shows available commands.
5437type Commands struct {
5538 com *common.Common
@@ -149,10 +132,12 @@ func (c *Commands) ID() string {
149132}
150133
151134// Update implements Dialog.
152−func (c *Commands) Update(msg tea.Msg) tea.Cmd {
135+func (c *Commands) Update(msg tea.Msg) tea.Msg {
153136 switch msg := msg.(type) {
154137 case tea.KeyPressMsg:
155138 switch {
139+ case key.Matches(msg, c.keyMap.Close):
140+ return CloseMsg{}
156141 case key.Matches(msg, c.keyMap.Previous):
157142 c.list.Focus()
158143 c.list.SelectPrev()
@@ -175,8 +160,10 @@ func (c *Commands) Update(msg tea.Msg) tea.Cmd {
175160 default:
176161 var cmd tea.Cmd
177162 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)
180167 return cmd
181168 }
182169 }
@@ -195,14 +182,17 @@ func (c *Commands) ReloadMCPPrompts() tea.Cmd {
195182
196183// Cursor returns the cursor position relative to the dialog.
197184func (c *Commands) Cursor() *tea.Cursor {
198− return c.input.Cursor()
185+ return InputCursor(c.com.Styles, c.input.Cursor())
199186}
200187
201−// View implements [Dialog].
202−func (c *Commands) View() string {
203− t := c.com.Styles
188+// radioView generates the command type selector radio buttons.
189+func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
190+ if !hasUserCmds && !hasMCPPrompts {
191+ return ""
192+ }
193+
204194 selectedFn := func(t uicmd.CommandType) string {
205− if t == c.selected {
195+ if t == selected {
206196 return "◉ " + t.String()
207197 }
208198 return "○ " + t.String()
@@ -211,46 +201,27 @@ func (c *Commands) View() string {
211201 parts := []string{
212202 selectedFn(uicmd.SystemCommands),
213203 }
214− if len(c.userCmds) > 0 {
204+ if hasUserCmds {
215205 parts = append(parts, selectedFn(uicmd.UserCommands))
216206 }
217− if c.mcpPrompts.Len() > 0 {
207+ if hasMCPPrompts {
218208 parts = append(parts, selectedFn(uicmd.MCPPrompts))
219209 }
220210
221211 radio := strings.Join(parts, " ")
222− radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
223− if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
224− radio = " " + radio
225− }
212+ return t.Dialog.Commands.CommandTypeSelector.Render(radio)
213+}
226214
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)
227219 titleStyle := t.Dialog.Title
228− helpStyle := t.Dialog.HelpView
229220 dialogStyle := t.Dialog.View.Width(c.width)
230− inputStyle := t.Dialog.InputPrompt
231− helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
232−
233221 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
234222 header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
235− title := titleStyle.Render(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(),
249− "",
250− help,
251− }, "\n")
252−
253− return dialogStyle.Render(content)
223+ return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
224+ c.input.View(), c.list.Render(), c.help.View(c))
254225}
255226
256227// ShortHelp implements [help.KeyMap].
@@ -316,7 +287,9 @@ func (c *Commands) setCommandType(commandType uicmd.CommandType) {
316287 }
317288
318289 c.list.SetItems(commandItems...)
319− // Reset selection and filter
290+ c.list.SetSelected(0)
291+ c.list.SetFilter("")
292+ c.list.ScrollToTop()
320293 c.list.SetSelected(0)
321294 c.input.SetValue("")
322295}
@@ -485,7 +458,7 @@ func (c *Commands) defaultCommands() []uicmd.Command {
485458 Description: "Quit",
486459 Shortcut: "ctrl+c",
487460 Handler: func(cmd uicmd.Command) tea.Cmd {
488− return uiutil.CmdHandler(QuitMsg{})
461+ return uiutil.CmdHandler(tea.QuitMsg{})
489462 },
490463 },
491464 }...)
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(
1717// Dialog is a component that can be displayed on top of the UI.
1818type Dialog interface {
1919 ID() string
20− Update(msg tea.Msg) tea.Cmd
20+ Update(msg tea.Msg) tea.Msg
2121 View() string
2222}
2323
@@ -71,6 +71,14 @@ func (d *Overlay) RemoveDialog(dialogID string) {
7171 }
7272}
7373
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+
7482// Dialog returns the dialog with the specified ID, or nil if not found.
7583func (d *Overlay) Dialog(dialogID string) Dialog {
7684 for _, dialog := range d.dialogs {
@@ -81,6 +89,14 @@ func (d *Overlay) Dialog(dialogID string) Dialog {
8189 return nil
8290}
8391
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+
84100// BringToFront brings the dialog with the specified ID to the front.
85101func (d *Overlay) BringToFront(dialogID string) {
86102 for i, dialog := range d.dialogs {
@@ -94,38 +110,40 @@ func (d *Overlay) BringToFront(dialogID string) {
94110}
95111
96112// 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 {
98114 if len(d.dialogs) == 0 {
99− return d, nil
115+ return nil
100116 }
101117
102118 idx := len(d.dialogs) - 1 // active dialog is the last one
103119 dialog := d.dialogs[idx]
104− switch msg := msg.(type) {
105− case tea.KeyPressMsg:
106− if key.Matches(msg, CloseKey) {
107− // Close the current dialog
108− d.removeDialog(idx)
109− return d, nil
110− }
120+ if dialog == nil {
121+ return nil
111122 }
112123
113− if cmd := dialog.Update(msg); cmd != nil {
114− // Close the current dialog
115− d.removeDialog(idx)
116− return d, cmd
124+ return dialog.Update(msg)
125+}
126+
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{}
117132 }
133+ return d.centerPositionView(area, dialog.View())
134+}
118135
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)
120140}
121141
122142// Draw renders the overlay and its dialogs.
123143func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) {
124144 for _, dialog := range d.dialogs {
125145 view := dialog.View()
126− viewWidth := lipgloss.Width(view)
127− viewHeight := lipgloss.Height(view)
128− center := common.CenterRect(area, viewWidth, viewHeight)
146+ center := d.centerPositionView(area, view)
129147 if area.Overlaps(center) {
130148 uv.NewStyledString(view).Draw(scr, center)
131149 }
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 {
6060}
6161
6262// Update implements [Model].
63−func (q *Quit) Update(msg tea.Msg) tea.Cmd {
63+func (q *Quit) Update(msg tea.Msg) tea.Msg {
6464 switch msg := msg.(type) {
6565 case tea.KeyPressMsg:
6666 switch {
67+ case key.Matches(msg, q.keyMap.Close):
68+ return CloseMsg{}
6769 case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
6870 q.selectedNo = !q.selectedNo
69− return nil
71+ return CloseMsg{}
7072 case key.Matches(msg, q.keyMap.EnterSpace):
7173 if !q.selectedNo {
72− return tea.Quit
74+ return QuitMsg{}
7375 }
74− return nil
76+ return CloseMsg{}
7577 case key.Matches(msg, q.keyMap.Yes):
76− return tea.Quit
78+ return QuitMsg{}
7779 case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
78− return nil
80+ return CloseMsg{}
7981 }
8082 }
8183
internal/ui/dialog/sessions.go+18−48
@@ -1,13 +1,10 @@
11package dialog
22
33import (
4− "strings"
5−
64 "charm.land/bubbles/v2/help"
75 "charm.land/bubbles/v2/key"
86 "charm.land/bubbles/v2/textinput"
97 tea "charm.land/bubbletea/v2"
10− "charm.land/lipgloss/v2"
118 "github.com/charmbracelet/crush/internal/session"
129 "github.com/charmbracelet/crush/internal/ui/common"
1310 "github.com/charmbracelet/crush/internal/ui/list"
@@ -34,11 +31,6 @@ type Session struct {
3431
3532var _ Dialog = (*Session)(nil)
3633
37−// SessionSelectedMsg is a message sent when a session is selected.
38−type SessionSelectedMsg struct {
39− Session session.Session
40−}
41−
4234// NewSessions creates a new Session dialog.
4335func NewSessions(com *common.Common, sessions ...session.Session) *Session {
4436 s := new(Session)
@@ -73,11 +65,6 @@ func NewSessions(com *common.Common, sessions ...session.Session) *Session {
7365 return s
7466}
7567
76−// Cursor returns the cursor position relative to the dialog.
77−func (s *Session) Cursor() *tea.Cursor {
78− return s.input.Cursor()
79−}
80−
8168// SetSize sets the size of the dialog.
8269func (s *Session) SetSize(width, height int) {
8370 s.width = width
@@ -94,10 +81,12 @@ func (s *Session) ID() string {
9481}
9582
9683// Update implements Dialog.
97−func (s *Session) Update(msg tea.Msg) tea.Cmd {
84+func (s *Session) Update(msg tea.Msg) tea.Msg {
9885 switch msg := msg.(type) {
9986 case tea.KeyPressMsg:
10087 switch {
88+ case key.Matches(msg, s.keyMap.Close):
89+ return CloseMsg{}
10190 case key.Matches(msg, s.keyMap.Previous):
10291 s.list.Focus()
10392 s.list.SelectPrev()
@@ -109,48 +98,36 @@ func (s *Session) Update(msg tea.Msg) tea.Cmd {
10998 case key.Matches(msg, s.keyMap.Select):
11099 if item := s.list.SelectedItem(); item != nil {
111100 sessionItem := item.(*SessionItem)
112− return SessionSelectCmd(sessionItem.Session)
101+ return SessionSelectedMsg{sessionItem.Session}
113102 }
114103 default:
115104 var cmd tea.Cmd
116105 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)
118110 return cmd
119111 }
120112 }
121113 return nil
122114}
123115
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+
124121// View implements [Dialog].
125122func (s *Session) View() string {
126123 titleStyle := s.com.Styles.Dialog.Title
127− helpStyle := s.com.Styles.Dialog.HelpView
128124 dialogStyle := s.com.Styles.Dialog.View.Width(s.width)
129− inputStyle := s.com.Styles.Dialog.InputPrompt
130− helpStyle = helpStyle.Width(s.width - dialogStyle.GetHorizontalFrameSize())
131− listContent := s.list.Render()
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− }
125+ header := common.DialogTitle(s.com.Styles, "Switch Session",
126+ max(0, s.width-dialogStyle.GetHorizontalFrameSize()-
127+ titleStyle.GetHorizontalFrameSize()))
136128
137− content := strings.Join([]string{
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)),
151− }, "\n")
152−
153− return dialogStyle.Render(content)
129+ return HeaderInputListHelpView(s.com.Styles, s.width, s.list.Height(), header,
130+ s.input.View(), s.list.Render(), s.help.View(s))
154131}
155132
156133// ShortHelp implements [help.KeyMap].
@@ -181,10 +158,3 @@ func (s *Session) FullHelp() [][]key.Binding {
181158 }
182159 return m
183160}
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 (
2020 "github.com/charmbracelet/crush/internal/message"
2121 "github.com/charmbracelet/crush/internal/pubsub"
2222 "github.com/charmbracelet/crush/internal/session"
23− "github.com/charmbracelet/crush/internal/tui/util"
2423 "github.com/charmbracelet/crush/internal/ui/common"
2524 "github.com/charmbracelet/crush/internal/ui/dialog"
2625 "github.com/charmbracelet/crush/internal/ui/logo"
2726 "github.com/charmbracelet/crush/internal/ui/styles"
27+ "github.com/charmbracelet/crush/internal/uiutil"
2828 "github.com/charmbracelet/crush/internal/version"
2929 uv "github.com/charmbracelet/ultraviolet"
3030 "github.com/charmbracelet/ultraviolet/screen"
@@ -194,12 +194,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194194 // TODO: Get. Rid. Of. Magic numbers!
195195 sessions.SetSize(min(120, m.width-8), 30)
196196 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− )
203197 case sessionLoadedMsg:
204198 m.state = uiChat
205199 m.session = &msg.sess
@@ -330,15 +324,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
330324 }
331325 case tea.KeyPressMsg:
332326 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)
342327 }
343328
344329 // 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) {
384369 return true
385370 }
386371 switch {
387− case key.Matches(msg, m.keyMap.Tab):
388372 case key.Matches(msg, m.keyMap.Help):
389373 m.help.ShowAll = !m.help.ShowAll
390374 m.updateLayoutAndSize()
@@ -400,7 +384,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
400384 }
401385 commands, err := dialog.NewCommands(m.com, sessionID)
402386 if err != nil {
403− cmds = append(cmds, util.ReportError(err))
387+ cmds = append(cmds, uiutil.ReportError(err))
404388 } else {
405389 // TODO: Get. Rid. Of. Magic numbers!
406390 commands.SetSize(min(120, m.width-8), 30)
@@ -427,64 +411,101 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
427411 return cmds
428412 }
429413
430− updatedDialog, cmd := m.dialog.Update(msg)
431− m.dialog = updatedDialog
432− if cmd != nil {
433− cmds = append(cmds, cmd)
414+ msg := m.dialog.Update(msg)
415+ if msg == nil {
416+ return cmds
434417 }
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+
435448 return cmds
436449 }
437450
438451 switch m.state {
439452 case uiChat:
440− switch {
441− 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 {
453+ switch m.focus {
454+ case uiFocusEditor:
455+ switch {
456+ case key.Matches(msg, m.keyMap.Tab):
447457 m.focus = uiFocusMain
448458 m.textarea.Blur()
449459 m.chat.Focus()
450460 m.chat.SetSelected(m.chat.Len() - 1)
461+ default:
462+ handleGlobalKeys(msg)
451463 }
452− case key.Matches(msg, m.keyMap.Chat.Up):
453− m.chat.ScrollBy(-1)
454− if !m.chat.SelectedItemInView() {
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()
470+ case key.Matches(msg, m.keyMap.Chat.Up):
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)
478+ if !m.chat.SelectedItemInView() {
479+ m.chat.SelectNext()
480+ m.chat.ScrollToSelected()
481+ }
482+ case key.Matches(msg, m.keyMap.Chat.UpOneItem):
455483 m.chat.SelectPrev()
456484 m.chat.ScrollToSelected()
457− }
458− case key.Matches(msg, m.keyMap.Chat.Down):
459− m.chat.ScrollBy(1)
460− if !m.chat.SelectedItemInView() {
485+ case key.Matches(msg, m.keyMap.Chat.DownOneItem):
461486 m.chat.SelectNext()
462487 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)
463508 }
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()
488509 default:
489510 handleGlobalKeys(msg)
490511 }
@@ -588,11 +609,29 @@ func (m *UI) Cursor() *tea.Cursor {
588609 // Don't show cursor if editor is not visible
589610 return nil
590611 }
591− if m.focus == uiFocusEditor && m.textarea.Focused() {
592− cur := m.textarea.Cursor()
593− cur.X++ // Adjust for app margins
594− cur.Y += m.layout.editor.Min.Y
595− return cur
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:
629+ if m.textarea.Focused() {
630+ cur := m.textarea.Cursor()
631+ cur.X++ // Adjust for app margins
632+ cur.Y += m.layout.editor.Min.Y
633+ return cur
634+ }
596635 }
597636 return nil
598637}
internal/ui/styles/styles.go+5−1
@@ -277,6 +277,8 @@ type Styles struct {
277277 SelectedItem lipgloss.Style
278278 InputPrompt lipgloss.Style
279279
280+ List lipgloss.Style
281+
280282 Commands struct {
281283 CommandTypeSelector lipgloss.Style
282284 }
@@ -909,7 +911,9 @@ func DefaultStyles() Styles {
909911 s.Dialog.Help.FullSeparator = base.Foreground(border)
910912 s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
911913 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)
913917
914918 s.Dialog.Commands.CommandTypeSelector = base.Foreground(fgHalfMuted)
915919