From d9e67367226436d629078fe392e38bc2d61aefeb Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 17 Nov 2025 17:59:06 -0500 Subject: [PATCH] feat(ui): simplify editor and embed into main UI model --- internal/ui/model/editor.go | 235 ------------------------------- internal/ui/model/keys.go | 64 ++++++++- internal/ui/model/ui.go | 267 ++++++++++++++++++++++++++++++------ 3 files changed, 285 insertions(+), 281 deletions(-) delete mode 100644 internal/ui/model/editor.go diff --git a/internal/ui/model/editor.go b/internal/ui/model/editor.go deleted file mode 100644 index d3c5afd703904473854617ddeda5e394952f7613..0000000000000000000000000000000000000000 --- a/internal/ui/model/editor.go +++ /dev/null @@ -1,235 +0,0 @@ -package model - -import ( - "math/rand" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/ui/common" -) - -type EditorKeyMap struct { - AddFile key.Binding - SendMessage key.Binding - OpenEditor key.Binding - Newline key.Binding - - // Attachments key maps - AttachmentDeleteMode key.Binding - Escape key.Binding - DeleteAllAttachments key.Binding -} - -func DefaultEditorKeyMap() EditorKeyMap { - return EditorKeyMap{ - AddFile: key.NewBinding( - key.WithKeys("/"), - key.WithHelp("/", "add file"), - ), - SendMessage: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "send"), - ), - OpenEditor: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "open editor"), - ), - Newline: key.NewBinding( - key.WithKeys("shift+enter", "ctrl+j"), - // "ctrl+j" is a common keybinding for newline in many editors. If - // the terminal supports "shift+enter", we substitute the help text - // to reflect that. - key.WithHelp("ctrl+j", "newline"), - ), - AttachmentDeleteMode: key.NewBinding( - key.WithKeys("ctrl+r"), - key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), - ), - Escape: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel delete mode"), - ), - DeleteAllAttachments: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attachments"), - ), - } -} - -// EditorModel represents the editor UI model. -type EditorModel struct { - com *common.Common - - keyMap EditorKeyMap - textarea *textarea.Model - - attachments []any // TODO: Implement attachments - - readyPlaceholder string - workingPlaceholder string -} - -// NewEditorModel creates a new instance of EditorModel. -func NewEditorModel(com *common.Common) *EditorModel { - ta := textarea.New() - ta.SetStyles(com.Styles.TextArea) - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.SetVirtualCursor(false) - ta.Focus() - e := &EditorModel{ - com: com, - keyMap: DefaultEditorKeyMap(), - textarea: ta, - } - - e.setEditorPrompt() - e.randomizePlaceholders() - e.textarea.Placeholder = e.readyPlaceholder - - return e -} - -// Init initializes the editor model. -func (m *EditorModel) Init() tea.Cmd { - return nil -} - -// Update handles updates to the editor model. -func (m *EditorModel) Update(msg tea.Msg) (*EditorModel, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - // Textarea placeholder logic - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder - } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.com.App.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" - } - - // TODO: Add attachments - - return m, tea.Batch(cmds...) -} - -// View renders the editor model. -func (m *EditorModel) View() string { - return m.textarea.View() -} - -// ShortHelp returns the short help view for the editor model. -func (m *EditorModel) ShortHelp() []key.Binding { - k := m.keyMap - binds := []key.Binding{ - k.AddFile, - k.SendMessage, - k.OpenEditor, - k.Newline, - } - - if len(m.attachments) > 0 { - binds = append(binds, - k.AttachmentDeleteMode, - k.DeleteAllAttachments, - k.Escape, - ) - } - - return binds -} - -// FullHelp returns the full help view for the editor model. -func (m *EditorModel) FullHelp() [][]key.Binding { - return [][]key.Binding{ - m.ShortHelp(), - } -} - -// Cursor returns the relative cursor position of the editor. -func (m *EditorModel) Cursor() *tea.Cursor { - return m.textarea.Cursor() -} - -// Blur implements Container. -func (m *EditorModel) Blur() tea.Cmd { - m.textarea.Blur() - return nil -} - -// Focus implements Container. -func (m *EditorModel) Focus() tea.Cmd { - return m.textarea.Focus() -} - -// Focused returns whether the editor is focused. -func (m *EditorModel) Focused() bool { - return m.textarea.Focused() -} - -// SetSize sets the size of the editor. -func (m *EditorModel) SetSize(width, height int) { - m.textarea.SetWidth(width) - m.textarea.SetHeight(height) -} - -func (m *EditorModel) setEditorPrompt() { - if m.com.App.Permissions.SkipRequests() { - m.textarea.SetPromptFunc(4, m.yoloPromptFunc) - return - } - m.textarea.SetPromptFunc(4, m.normalPromptFunc) -} - -func (m *EditorModel) normalPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - return " > " - } - if info.Focused { - return t.EditorPromptNormalFocused.Render() - } - return t.EditorPromptNormalBlurred.Render() -} - -func (m *EditorModel) yoloPromptFunc(info textarea.PromptInfo) string { - t := m.com.Styles - if info.LineNumber == 0 { - if info.Focused { - return t.EditorPromptYoloIconFocused.Render() - } else { - return t.EditorPromptYoloIconBlurred.Render() - } - } - if info.Focused { - return t.EditorPromptYoloDotsFocused.Render() - } - return t.EditorPromptYoloDotsBlurred.Render() -} - -var readyPlaceholders = [...]string{ - "Ready!", - "Ready...", - "Ready?", - "Ready for instructions", -} - -var workingPlaceholders = [...]string{ - "Working!", - "Working...", - "Brrrrr...", - "Prrrrrrrr...", - "Processing...", - "Thinking...", -} - -func (m *EditorModel) randomizePlaceholders() { - m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] - m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] -} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index d6b51c374b55dffd55be76405724b40d36a39c05..ff7a9344b54182cafc9cbaf979cc3c0112107743 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -3,16 +3,32 @@ package model import "charm.land/bubbles/v2/key" type KeyMap struct { + Editor struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + Newline key.Binding + AddImage key.Binding + MentionFile key.Binding + + // Attachments key maps + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding + } + + // Global key maps Quit key.Binding Help key.Binding Commands key.Binding + Models key.Binding Suspend key.Binding Sessions key.Binding Tab key.Binding } func DefaultKeyMap() KeyMap { - return KeyMap{ + km := KeyMap{ Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -25,6 +41,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "commands"), ), + Models: key.NewBinding( + key.WithKeys("ctrl+m", "ctrl+l"), + key.WithHelp("ctrl+l", "models"), + ), Suspend: key.NewBinding( key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "suspend"), @@ -38,4 +58,46 @@ func DefaultKeyMap() KeyMap { key.WithHelp("tab", "change focus"), ), } + + km.Editor.AddFile = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ) + km.Editor.SendMessage = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ) + km.Editor.OpenEditor = key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "open editor"), + ) + km.Editor.Newline = key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help tex + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + km.Editor.AddImage = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ) + km.Editor.MentionFile = key.NewBinding( + key.WithKeys("@"), + key.WithHelp("@", "mention file"), + ) + km.Editor.AttachmentDeleteMode = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ) + km.Editor.Escape = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel delete mode"), + ) + km.Editor.DeleteAllAttachments = key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ) + + return km } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index b313deb815439dafc225d574ae3a3b2084675ddd..359bfb60b07817ded09347524698d33eab452994 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -8,8 +8,10 @@ import ( "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" uv "github.com/charmbracelet/ultraviolet" @@ -20,20 +22,21 @@ type uiState uint8 // Possible uiState values. const ( - uiChat uiState = iota - uiEdit + uiEdit uiState = iota + uiChat ) // UI represents the main user interface model. type UI struct { - com *common.Common + com *common.Common + sess *session.Session state uiState keyMap KeyMap + keyenh tea.KeyboardEnhancementsMsg chat *ChatModel - editor *EditorModel side *SidebarModel dialog *dialog.Overlay help help.Model @@ -47,18 +50,40 @@ type UI struct { // QueryVersion instructs the TUI to query for the terminal version when it // starts. QueryVersion bool + + // Editor components + textarea textarea.Model + + attachments []any // TODO: Implement attachments + + readyPlaceholder string + workingPlaceholder string } // New creates a new instance of the [UI] model. func New(com *common.Common) *UI { - return &UI{ - com: com, - dialog: dialog.NewOverlay(), - keyMap: DefaultKeyMap(), - editor: NewEditorModel(com), - side: NewSidebarModel(com), - help: help.New(), + // Editor components + ta := textarea.New() + ta.SetStyles(com.Styles.TextArea) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + + ui := &UI{ + com: com, + dialog: dialog.NewOverlay(), + keyMap: DefaultKeyMap(), + side: NewSidebarModel(com), + help: help.New(), + textarea: ta, } + + ui.setEditorPrompt() + ui.randomizePlaceholders() + ui.textarea.Placeholder = ui.readyPlaceholder + + return ui } // Init initializes the UI model. @@ -73,6 +98,7 @@ func (m *UI) Init() tea.Cmd { // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + hasDialogs := m.dialog.HasDialogs() switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? @@ -88,18 +114,30 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.WindowSizeMsg: m.updateLayoutAndSize(msg.Width, msg.Height) + case tea.KeyboardEnhancementsMsg: + m.keyenh = msg + if msg.SupportsKeyDisambiguation() { + m.keyMap.Models.SetHelp("ctrl+m", "models") + m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") + } case tea.KeyPressMsg: - if m.dialog.HasDialogs() { + if hasDialogs { m.updateDialogs(msg, &cmds) - } else { + } + } + + if !hasDialogs { + // This branch only handles UI elements when there's no dialog shown. + switch msg := msg.(type) { + case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Tab): if m.state == uiChat { m.state = uiEdit - cmds = append(cmds, m.editor.Focus()) + cmds = append(cmds, m.textarea.Focus()) } else { m.state = uiChat - cmds = append(cmds, m.editor.Blur()) + m.textarea.Blur() } case key.Matches(msg, m.keyMap.Help): m.help.ShowAll = !m.help.ShowAll @@ -109,10 +147,31 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.AddDialog(dialog.NewQuit(m.com)) return m, nil } + case key.Matches(msg, m.keyMap.Commands): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Models): + // TODO: Implement me + case key.Matches(msg, m.keyMap.Sessions): + // TODO: Implement me default: m.updateFocused(msg, &cmds) } } + + // This logic gets triggered on any message type, but should it? + switch m.state { + case uiChat: + case uiEdit: + // Textarea placeholder logic + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" + } + } } return m, tea.Batch(cmds...) @@ -126,7 +185,7 @@ func (m *UI) View() tea.View { layers := []*lipgloss.Layer{} // Determine the help key map based on focus - helpKeyMap := m.focusedKeyMap() + var helpKeyMap help.KeyMap = m // The screen areas we're working with area := m.layout.area @@ -137,11 +196,6 @@ func (m *UI) View() tea.View { if m.dialog.HasDialogs() { if dialogView := m.dialog.View(); dialogView != "" { - // If the dialog has its own help, use that instead - if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 { - helpKeyMap = m.dialog - } - dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) layers = append(layers, @@ -153,8 +207,8 @@ func (m *UI) View() tea.View { } } - if m.state == uiEdit && m.editor.Focused() { - cur := m.editor.Cursor() + if m.state == uiEdit && m.textarea.Focused() { + cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += editRect.Min.Y v.Cursor = cur @@ -171,7 +225,7 @@ func (m *UI) View() tea.View { ).X(chatRect.Min.X).Y(chatRect.Min.Y), lipgloss.NewLayer(m.side.View()). X(sideRect.Min.X).Y(sideRect.Min.Y), - lipgloss.NewLayer(m.editor.View()). + lipgloss.NewLayer(m.textarea.View()). X(editRect.Min.X).Y(editRect.Min.Y), lipgloss.NewLayer(m.help.View(helpKeyMap)). X(helpRect.Min.X).Y(helpRect.Min.Y), @@ -189,11 +243,82 @@ func (m *UI) View() tea.View { return v } -func (m *UI) focusedKeyMap() help.KeyMap { - if m.state == uiChat { - return m.chat +// ShortHelp implements [help.KeyMap]. +func (m *UI) ShortHelp() []key.Binding { + var binds []key.Binding + k := &m.keyMap + + if m.sess == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + } else { + // we have a session } - return m.editor + + // 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, + // ) + // } + // } + + return binds +} + +// FullHelp implements [help.KeyMap]. +func (m *UI) FullHelp() [][]key.Binding { + var binds [][]key.Binding + k := &m.keyMap + help := k.Help + help.SetHelp("ctrl+g", "less") + + if m.sess == nil { + // no session selected + binds = append(binds, + []key.Binding{ + k.Commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + []key.Binding{ + help, + }, + ) + } else { + // we have a session + } + + // switch m.state { + // case uiChat: + // case uiEdit: + // binds = append(binds, m.ShortHelp()) + // } + + return binds } // updateDialogs updates the dialog overlay with the given message and appends @@ -213,7 +338,16 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { case uiChat: m.updateChat(msg, cmds) case uiEdit: - m.updateEditor(msg, cmds) + switch { + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + } + + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + if cmd != nil { + *cmds = append(*cmds, cmd) + } } } @@ -227,26 +361,13 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { } } -// updateEditor updates the editor model with the given message and appends any -// resulting commands to the cmds slice. -func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - updatedEditor, cmd := m.editor.Update(msg) - m.editor = updatedEditor - if cmd != nil { - *cmds = append(*cmds, cmd) - } -} - // updateLayoutAndSize updates the layout and sub-models sizes based on the // given terminal width and height given in cells. func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) - helpKeyMap := m.focusedKeyMap() + var helpKeyMap help.KeyMap = m helpHeight := 1 - if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 { - helpKeyMap = m.dialog - } if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) @@ -280,8 +401,9 @@ func (m *UI) updateLayoutAndSize(w, h int) { // Update sub-model sizes m.side.SetWidth(m.layout.sidebar.Dx()) - m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy()) - m.help.Width = m.layout.help.Dx() + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + m.help.SetWidth(m.layout.help.Dx()) } // layout defines the positioning of UI elements. @@ -304,3 +426,58 @@ type layout struct { // help is the area for the help view. help uv.Rectangle } + +func (m *UI) setEditorPrompt() { + if m.com.App.Permissions.SkipRequests() { + m.textarea.SetPromptFunc(4, m.yoloPromptFunc) + return + } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) +} + +func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + return " > " + } + if info.Focused { + return t.EditorPromptNormalFocused.Render() + } + return t.EditorPromptNormalBlurred.Render() +} + +func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.EditorPromptYoloIconFocused.Render() + } else { + return t.EditorPromptYoloIconBlurred.Render() + } + } + if info.Focused { + return t.EditorPromptYoloDotsFocused.Render() + } + return t.EditorPromptYoloDotsBlurred.Render() +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +func (m *UI) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +}