fix(ui): improve key handling and keybindings for chat/editor

Ayman Bagabas created

Change summary

internal/ui/model/keys.go |  50 ++++++++---
internal/ui/model/ui.go   | 178 ++++++++++++++++++++++++++++++----------
2 files changed, 167 insertions(+), 61 deletions(-)

Detailed changes

internal/ui/model/keys.go 🔗

@@ -18,21 +18,25 @@ type KeyMap struct {
 	}
 
 	Chat struct {
-		NewSession    key.Binding
-		AddAttachment key.Binding
-		Cancel        key.Binding
-		Tab           key.Binding
-		Details       key.Binding
-		Down          key.Binding
-		Up            key.Binding
-		DownOneItem   key.Binding
-		UpOneItem     key.Binding
-		PageDown      key.Binding
-		PageUp        key.Binding
-		HalfPageDown  key.Binding
-		HalfPageUp    key.Binding
-		Home          key.Binding
-		End           key.Binding
+		NewSession     key.Binding
+		AddAttachment  key.Binding
+		Cancel         key.Binding
+		Tab            key.Binding
+		Details        key.Binding
+		Down           key.Binding
+		Up             key.Binding
+		UpDown         key.Binding
+		DownOneItem    key.Binding
+		UpOneItem      key.Binding
+		UpDownOneItem  key.Binding
+		PageDown       key.Binding
+		PageUp         key.Binding
+		HalfPageDown   key.Binding
+		HalfPageUp     key.Binding
+		Home           key.Binding
+		End            key.Binding
+		Copy           key.Binding
+		ClearHighlight key.Binding
 	}
 
 	Initialize struct {
@@ -153,6 +157,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
 		key.WithHelp("↑", "up"),
 	)
+	km.Chat.UpDown = key.NewBinding(
+		key.WithKeys("up", "down"),
+		key.WithHelp("↑↓", "scroll"),
+	)
 	km.Chat.UpOneItem = key.NewBinding(
 		key.WithKeys("shift+up", "K"),
 		key.WithHelp("shift+↑", "up one item"),
@@ -161,6 +169,10 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("shift+down", "J"),
 		key.WithHelp("shift+↓", "down one item"),
 	)
+	km.Chat.UpDownOneItem = key.NewBinding(
+		key.WithKeys("shift+up", "shift+down"),
+		key.WithHelp("shift+↑↓", "scroll one item"),
+	)
 	km.Chat.HalfPageDown = key.NewBinding(
 		key.WithKeys("d"),
 		key.WithHelp("d", "half page down"),
@@ -185,6 +197,14 @@ func DefaultKeyMap() KeyMap {
 		key.WithKeys("G", "end"),
 		key.WithHelp("G", "end"),
 	)
+	km.Chat.Copy = key.NewBinding(
+		key.WithKeys("c", "y", "C", "Y"),
+		key.WithHelp("c/y", "copy"),
+	)
+	km.Chat.ClearHighlight = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "clear selection"),
+	)
 
 	km.Initialize.Yes = key.NewBinding(
 		key.WithKeys("y", "Y"),

internal/ui/model/ui.go 🔗

@@ -313,7 +313,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case tea.KeyPressMsg:
-		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
+		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case tea.PasteMsg:
 		if cmd := m.handlePasteMsg(msg); cmd != nil {
 			cmds = append(cmds, cmd)
@@ -341,7 +343,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	var cmds []tea.Cmd
+
 	handleQuitKeys := func(msg tea.KeyPressMsg) bool {
 		switch {
 		case key.Matches(msg, m.keyMap.Quit):
@@ -369,6 +373,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 			return true
 		case key.Matches(msg, m.keyMap.Models):
 			// TODO: Implement me
+			return true
 		case key.Matches(msg, m.keyMap.Sessions):
 			if m.dialog.ContainsDialog(dialog.SessionsID) {
 				// Bring to front
@@ -385,12 +390,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	if m.dialog.HasDialogs() {
 		// Always handle quit keys first
 		if handleQuitKeys(msg) {
-			return cmds
+			return tea.Batch(cmds...)
 		}
 
 		msg := m.dialog.Update(msg)
 		if msg == nil {
-			return cmds
+			return tea.Batch(cmds...)
 		}
 
 		switch msg := msg.(type) {
@@ -424,18 +429,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 			cmds = append(cmds, tea.Quit)
 		}
 
-		return cmds
+		return tea.Batch(cmds...)
 	}
 
 	switch m.state {
 	case uiConfigure:
-		return cmds
+		return tea.Batch(cmds...)
 	case uiInitialize:
-		return append(cmds, m.updateInitializeView(msg)...)
+		cmds = append(cmds, m.updateInitializeView(msg)...)
+		return tea.Batch(cmds...)
 	case uiChat, uiLanding, uiChatCompact:
 		switch m.focus {
 		case uiFocusEditor:
 			switch {
+			case key.Matches(msg, m.keyMap.Editor.SendMessage):
+				// TODO: Implement me
 			case key.Matches(msg, m.keyMap.Tab):
 				m.focus = uiFocusMain
 				m.textarea.Blur()
@@ -447,6 +455,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 					break
 				}
 				cmds = append(cmds, m.openEditor(m.textarea.Value()))
+			case key.Matches(msg, m.keyMap.Editor.Newline):
+				m.textarea.InsertRune('\n')
 			default:
 				if handleGlobalKeys(msg) {
 					// Handle global keys first before passing to textarea.
@@ -509,12 +519,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 		handleGlobalKeys(msg)
 	}
 
-	return cmds
+	return tea.Batch(cmds...)
 }
 
 // Draw implements [tea.Layer] and draws the UI model.
 func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
-	layout := generateLayout(m, area.Dx(), area.Dy())
+	layout := m.generateLayout(area.Dx(), area.Dy())
 
 	if m.layout != layout {
 		m.layout = layout
@@ -665,46 +675,58 @@ func (m *UI) View() tea.View {
 func (m *UI) ShortHelp() []key.Binding {
 	var binds []key.Binding
 	k := &m.keyMap
+	tab := k.Tab
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
 
 	switch m.state {
 	case uiInitialize:
 		binds = append(binds, k.Quit)
+	case uiChat:
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		binds = append(binds,
+			tab,
+			commands,
+			k.Models,
+		)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				k.Editor.Newline,
+			)
+		case uiFocusMain:
+			binds = append(binds,
+				k.Chat.UpDown,
+				k.Chat.UpDownOneItem,
+				k.Chat.PageUp,
+				k.Chat.PageDown,
+				k.Chat.Copy,
+			)
+		}
 	default:
 		// TODO: other states
 		// if m.session == nil {
 		// no session selected
 		binds = append(binds,
-			k.Commands,
+			commands,
 			k.Models,
 			k.Editor.Newline,
-			k.Quit,
-			k.Help,
 		)
-		// }
-		// else {
-		// we have a session
-		// }
-
-		// switch m.state {
-		// case uiChat:
-		// case uiEdit:
-		// 	binds = append(binds,
-		// 		k.Editor.AddFile,
-		// 		k.Editor.SendMessage,
-		// 		k.Editor.OpenEditor,
-		// 		k.Editor.Newline,
-		// 	)
-		//
-		// 	if len(m.attachments) > 0 {
-		// 		binds = append(binds,
-		// 			k.Editor.AttachmentDeleteMode,
-		// 			k.Editor.DeleteAllAttachments,
-		// 			k.Editor.Escape,
-		// 		)
-		// 	}
-		// }
 	}
 
+	binds = append(binds,
+		k.Quit,
+		k.Help,
+	)
+
 	return binds
 }
 
@@ -714,6 +736,12 @@ func (m *UI) FullHelp() [][]key.Binding {
 	k := &m.keyMap
 	help := k.Help
 	help.SetHelp("ctrl+g", "less")
+	hasAttachments := false // TODO: implement attachments
+	hasSession := m.session != nil && m.session.ID != ""
+	commands := k.Commands
+	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
+		commands.SetHelp("/ or ctrl+p", "commands")
+	}
 
 	switch m.state {
 	case uiInitialize:
@@ -721,12 +749,72 @@ func (m *UI) FullHelp() [][]key.Binding {
 			[]key.Binding{
 				k.Quit,
 			})
+	case uiChat:
+		mainBinds := []key.Binding{}
+		tab := k.Tab
+		if m.focus == uiFocusEditor {
+			tab.SetHelp("tab", "focus chat")
+		} else {
+			tab.SetHelp("tab", "focus editor")
+		}
+
+		mainBinds = append(mainBinds,
+			tab,
+			commands,
+			k.Models,
+			k.Sessions,
+		)
+		if hasSession {
+			mainBinds = append(mainBinds, k.Chat.NewSession)
+		}
+
+		binds = append(binds, mainBinds)
+
+		switch m.focus {
+		case uiFocusEditor:
+			binds = append(binds,
+				[]key.Binding{
+					k.Editor.Newline,
+					k.Editor.AddImage,
+					k.Editor.MentionFile,
+					k.Editor.OpenEditor,
+				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+		case uiFocusMain:
+			binds = append(binds,
+				[]key.Binding{
+					k.Chat.UpDown,
+					k.Chat.UpDownOneItem,
+					k.Chat.PageUp,
+					k.Chat.PageDown,
+				},
+				[]key.Binding{
+					k.Chat.HalfPageUp,
+					k.Chat.HalfPageDown,
+					k.Chat.Home,
+					k.Chat.End,
+				},
+				[]key.Binding{
+					k.Chat.Copy,
+					k.Chat.ClearHighlight,
+				},
+			)
+		}
 	default:
 		if m.session == nil {
 			// no session selected
 			binds = append(binds,
 				[]key.Binding{
-					k.Commands,
+					commands,
 					k.Models,
 					k.Sessions,
 				},
@@ -741,23 +829,21 @@ func (m *UI) FullHelp() [][]key.Binding {
 				},
 			)
 		}
-		// else {
-		// we have a session
-		// }
 	}
 
-	// switch m.state {
-	// case uiChat:
-	// case uiEdit:
-	// 	binds = append(binds, m.ShortHelp())
-	// }
+	binds = append(binds,
+		[]key.Binding{
+			help,
+			k.Quit,
+		},
+	)
 
 	return binds
 }
 
 // updateLayoutAndSize updates the layout and sizes of UI components.
 func (m *UI) updateLayoutAndSize() {
-	m.layout = generateLayout(m, m.width, m.height)
+	m.layout = m.generateLayout(m.width, m.height)
 	m.updateSize()
 }
 
@@ -786,7 +872,7 @@ func (m *UI) updateSize() {
 
 // generateLayout calculates the layout rectangles for all UI components based
 // on the current UI state and terminal dimensions.
-func generateLayout(m *UI, w, h int) layout {
+func (m *UI) generateLayout(w, h int) layout {
 	// The screen area we're working with
 	area := image.Rect(0, 0, w, h)