@@ -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))]
-}
@@ -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
}
@@ -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))]
+}