feat(ui): add chat and editor models with keybindings

Ayman Bagabas created

Change summary

internal/ui/common/common.go    |  15 +
internal/ui/common/interface.go |  11 +
internal/ui/dialog/dialog.go    |   9 
internal/ui/dialog/quit.go      |   5 
internal/ui/model/chat.go       |  86 ++++++++
internal/ui/model/editor.go     | 201 +++++++++++++++++++
internal/ui/model/keys.go       |   5 
internal/ui/model/ui.go         | 217 ++++++++++++++++----
internal/ui/styles/styles.go    | 361 ++++++++++++++++++++++++++++++++++
9 files changed, 855 insertions(+), 55 deletions(-)

Detailed changes

internal/ui/common/common.go 🔗

@@ -1,8 +1,11 @@
 package common
 
 import (
+	"image"
+
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // Common defines common UI options and configurations.
@@ -18,3 +21,15 @@ func DefaultCommon(cfg *config.Config) *Common {
 		Styles: styles.DefaultStyles(),
 	}
 }
+
+// CenterRect returns a new [Rectangle] centered within the given area with the
+// specified width and height.
+func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
+	centerX := area.Min.X + area.Dx()/2
+	centerY := area.Min.Y + area.Dy()/2
+	minX := centerX - width/2
+	minY := centerY - height/2
+	maxX := minX + width
+	maxY := minY + height
+	return image.Rect(minX, minY, maxX, maxY)
+}

internal/ui/common/interface.go 🔗

@@ -0,0 +1,11 @@
+package common
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+)
+
+// Model represents a common interface for UI components.
+type Model[T any] interface {
+	Update(msg tea.Msg) (T, tea.Cmd)
+	View() string
+}

internal/ui/dialog/dialog.go 🔗

@@ -3,7 +3,7 @@ package dialog
 import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/ui/component"
+	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
@@ -23,7 +23,7 @@ func DefaultOverlayKeyMap() OverlayKeyMap {
 
 // Dialog is a component that can be displayed on top of the UI.
 type Dialog interface {
-	component.Model[Dialog]
+	common.Model[Dialog]
 	ID() string
 }
 
@@ -41,6 +41,11 @@ func NewOverlay(dialogs ...Dialog) *Overlay {
 	}
 }
 
+// HasDialogs checks if there are any active dialogs.
+func (d *Overlay) HasDialogs() bool {
+	return len(d.dialogs) > 0
+}
+
 // ContainsDialog checks if a dialog with the specified ID exists.
 func (d *Overlay) ContainsDialog(dialogID string) bool {
 	for _, dialog := range d.dialogs {

internal/ui/dialog/quit.go 🔗

@@ -7,6 +7,9 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
+// QuitDialogID is the identifier for the quit dialog.
+const QuitDialogID = "quit"
+
 // QuitDialogKeyMap represents key bindings for the quit dialog.
 type QuitDialogKeyMap struct {
 	LeftRight,
@@ -65,7 +68,7 @@ func NewQuit(com *common.Common) *Quit {
 
 // ID implements [Model].
 func (*Quit) ID() string {
-	return "quit"
+	return QuitDialogID
 }
 
 // Update implements [Model].

internal/ui/model/chat.go 🔗

@@ -0,0 +1,86 @@
+package model
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+// ChatKeyMap defines key bindings for the chat model.
+type ChatKeyMap struct {
+	NewSession    key.Binding
+	AddAttachment key.Binding
+	Cancel        key.Binding
+	Tab           key.Binding
+	Details       key.Binding
+}
+
+// DefaultChatKeyMap returns the default key bindings for the chat model.
+func DefaultChatKeyMap() ChatKeyMap {
+	return ChatKeyMap{
+		NewSession: key.NewBinding(
+			key.WithKeys("ctrl+n"),
+			key.WithHelp("ctrl+n", "new session"),
+		),
+		AddAttachment: key.NewBinding(
+			key.WithKeys("ctrl+f"),
+			key.WithHelp("ctrl+f", "add attachment"),
+		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc", "alt+esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
+		Details: key.NewBinding(
+			key.WithKeys("ctrl+d"),
+			key.WithHelp("ctrl+d", "toggle details"),
+		),
+	}
+}
+
+// ChatModel represents the chat UI model.
+type ChatModel struct {
+	app *app.App
+	com *common.Common
+
+	keyMap ChatKeyMap
+}
+
+// NewChatModel creates a new instance of ChatModel.
+func NewChatModel(com *common.Common, app *app.App) *ChatModel {
+	return &ChatModel{
+		app:    app,
+		com:    com,
+		keyMap: DefaultChatKeyMap(),
+	}
+}
+
+// Init initializes the chat model.
+func (m *ChatModel) Init() tea.Cmd {
+	return nil
+}
+
+// Update handles incoming messages and updates the chat model state.
+func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) {
+	// Handle messages here
+	return m, nil
+}
+
+// View renders the chat model's view.
+func (m *ChatModel) View() string {
+	return "Chat Model View"
+}
+
+// ShortHelp returns a brief help view for the chat model.
+func (m *ChatModel) ShortHelp() []key.Binding {
+	return nil
+}
+
+// FullHelp returns a detailed help view for the chat model.
+func (m *ChatModel) FullHelp() [][]key.Binding {
+	return nil
+}

internal/ui/model/editor.go 🔗

@@ -0,0 +1,201 @@
+package model
+
+import (
+	"math/rand"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textarea"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/ui/common"
+)
+
+type EditorKeyMap struct {
+	AddFile     key.Binding
+	SendMessage key.Binding
+	OpenEditor  key.Binding
+	Newline     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"),
+		),
+	}
+}
+
+// EditorModel represents the editor UI model.
+type EditorModel struct {
+	com *common.Common
+	app *app.App
+
+	keyMap   EditorKeyMap
+	textarea *textarea.Model
+
+	readyPlaceholder   string
+	workingPlaceholder string
+}
+
+// NewEditorModel creates a new instance of EditorModel.
+func NewEditorModel(com *common.Common, app *app.App) *EditorModel {
+	ta := textarea.New()
+	ta.SetStyles(com.Styles.TextArea)
+	ta.ShowLineNumbers = false
+	ta.CharLimit = -1
+	ta.SetVirtualCursor(false)
+	ta.Focus()
+	e := &EditorModel{
+		com:      com,
+		app:      app,
+		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.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
+		m.textarea.Placeholder = m.workingPlaceholder
+	} else {
+		m.textarea.Placeholder = m.readyPlaceholder
+	}
+	if m.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 {
+	return nil
+}
+
+// FullHelp returns the full help view for the editor model.
+func (m *EditorModel) FullHelp() [][]key.Binding {
+	return nil
+}
+
+// Cursor returns the relative cursor position of the editor.
+func (m *EditorModel) Cursor() *tea.Cursor {
+	return m.textarea.Cursor()
+}
+
+// Blur implements Container.
+func (c *EditorModel) Blur() tea.Cmd {
+	c.textarea.Blur()
+	return nil
+}
+
+// Focus implements Container.
+func (c *EditorModel) Focus() tea.Cmd {
+	return c.textarea.Focus()
+}
+
+// Focused returns whether the editor is focused.
+func (c *EditorModel) Focused() bool {
+	return c.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.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))]
+}

internal/ui/model/keys.go 🔗

@@ -8,6 +8,7 @@ type KeyMap struct {
 	Commands key.Binding
 	Suspend  key.Binding
 	Sessions key.Binding
+	Tab      key.Binding
 }
 
 func DefaultKeyMap() KeyMap {
@@ -32,5 +33,9 @@ func DefaultKeyMap() KeyMap {
 			key.WithKeys("ctrl+s"),
 			key.WithHelp("ctrl+s", "sessions"),
 		),
+		Tab: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "change focus"),
+		),
 	}
 }

internal/ui/model/ui.go 🔗

@@ -2,7 +2,9 @@ package model
 
 import (
 	"image"
+	"math/rand"
 
+	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
@@ -15,19 +17,25 @@ import (
 type uiState uint8
 
 const (
-	uiStateMain uiState = iota
+	uiChat uiState = iota
+	uiEdit
 )
 
 type UI struct {
 	app *app.App
 	com *common.Common
 
-	width, height int
-	state         uiState
+	state        uiState
+	showFullHelp bool
 
 	keyMap KeyMap
 
+	chat   *ChatModel
+	editor *EditorModel
 	dialog *dialog.Overlay
+	help   help.Model
+
+	layout layout
 }
 
 func New(com *common.Common, app *app.App) *UI {
@@ -36,6 +44,8 @@ func New(com *common.Common, app *app.App) *UI {
 		com:    com,
 		dialog: dialog.NewOverlay(),
 		keyMap: DefaultKeyMap(),
+		editor: NewEditorModel(com, app),
+		help:   help.New(),
 	}
 }
 
@@ -47,28 +57,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
+		m.updateLayout(msg.Width, msg.Height)
+		m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
 	case tea.KeyPressMsg:
-		switch m.state {
-		case uiStateMain:
+		if m.dialog.HasDialogs() {
+			m.updateDialogs(msg, &cmds)
+		} else {
 			switch {
+			case key.Matches(msg, m.keyMap.Tab):
+				if m.state == uiChat {
+					m.state = uiEdit
+					cmds = append(cmds, m.editor.Focus())
+				} else {
+					m.state = uiChat
+					cmds = append(cmds, m.editor.Blur())
+				}
+			case key.Matches(msg, m.keyMap.Help):
+				m.showFullHelp = !m.showFullHelp
+				m.help.ShowAll = m.showFullHelp
 			case key.Matches(msg, m.keyMap.Quit):
-				quitDialog := dialog.NewQuit(m.com)
-				if !m.dialog.ContainsDialog(quitDialog.ID()) {
-					m.dialog.AddDialog(quitDialog)
+				if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
+					m.dialog.AddDialog(dialog.NewQuit(m.com))
 					return m, nil
 				}
+			default:
+				m.updateFocused(msg, &cmds)
 			}
 		}
 	}
 
-	updatedDialog, cmd := m.dialog.Update(msg)
-	m.dialog = updatedDialog
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-
 	return m, tea.Batch(cmds...)
 }
 
@@ -76,41 +93,63 @@ func (m *UI) View() tea.View {
 	var v tea.View
 	v.AltScreen = true
 
-	// The screen area we're working with
-	area := image.Rect(0, 0, m.width, m.height)
 	layers := []*lipgloss.Layer{}
 
-	if dialogView := m.dialog.View(); dialogView != "" {
-		dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
-		dialogArea := centerRect(area, dialogWidth, dialogHeight)
-		layers = append(layers,
-			lipgloss.NewLayer(dialogView).
-				X(dialogArea.Min.X).
-				Y(dialogArea.Min.Y),
-		)
+	// Determine the help key map based on focus
+	helpKeyMap := m.focusedKeyMap()
+
+	// The screen areas we're working with
+	area := m.layout.area
+	chatRect := m.layout.chat
+	sideRect := m.layout.sidebar
+	editRect := m.layout.editor
+	helpRect := m.layout.help
+
+	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,
+				lipgloss.NewLayer(dialogView).
+					X(dialogArea.Min.X).
+					Y(dialogArea.Min.Y).
+					Z(99),
+			)
+		}
 	}
 
-	mainRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40))
-	mainRect, footRect := uv.SplitVertical(mainRect, uv.Fixed(area.Dy()-7))
+	if m.state == uiEdit && m.editor.Focused() {
+		cur := m.editor.Cursor()
+		cur.X++ // Adjust for app margins
+		cur.Y += editRect.Min.Y
+		v.Cursor = cur
+	}
 
 	layers = append(layers, lipgloss.NewLayer(
-		lipgloss.NewStyle().Width(mainRect.Dx()).
-			Height(mainRect.Dy()).
-			Border(lipgloss.NormalBorder()).
+		lipgloss.NewStyle().Width(chatRect.Dx()).
+			Height(chatRect.Dy()).
+			Background(lipgloss.ANSIColor(rand.Intn(256))).
 			Render(" Main View "),
-	).X(mainRect.Min.X).Y(mainRect.Min.Y),
+	).X(chatRect.Min.X).Y(chatRect.Min.Y),
 		lipgloss.NewLayer(
 			lipgloss.NewStyle().Width(sideRect.Dx()).
 				Height(sideRect.Dy()).
-				Border(lipgloss.NormalBorder()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
 				Render(" Side View "),
 		).X(sideRect.Min.X).Y(sideRect.Min.Y),
+		lipgloss.NewLayer(m.editor.View()).
+			X(editRect.Min.X).Y(editRect.Min.Y),
 		lipgloss.NewLayer(
-			lipgloss.NewStyle().Width(footRect.Dx()).
-				Height(footRect.Dy()).
-				Border(lipgloss.NormalBorder()).
-				Render(" Footer View "),
-		).X(footRect.Min.X).Y(footRect.Min.Y),
+			lipgloss.NewStyle().Width(helpRect.Dx()).
+				Height(helpRect.Dy()).
+				Background(lipgloss.ANSIColor(rand.Intn(256))).
+				Render(m.help.View(helpKeyMap)),
+		).X(helpRect.Min.X).Y(helpRect.Min.Y),
 	)
 
 	v.Layer = lipgloss.NewCanvas(layers...)
@@ -118,14 +157,96 @@ func (m *UI) View() tea.View {
 	return v
 }
 
-// centerRect returns a new [Rectangle] centered within the given area with the
-// specified width and height.
-func centerRect(area uv.Rectangle, width, height int) uv.Rectangle {
-	centerX := area.Min.X + area.Dx()/2
-	centerY := area.Min.Y + area.Dy()/2
-	minX := centerX - width/2
-	minY := centerY - height/2
-	maxX := minX + width
-	maxY := minY + height
-	return image.Rect(minX, minY, maxX, maxY)
+func (m *UI) focusedKeyMap() help.KeyMap {
+	if m.state == uiChat {
+		return m.chat
+	}
+	return m.editor
+}
+
+// updateDialogs updates the dialog overlay with the given message and appends
+// any resulting commands to the cmds slice.
+func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
+	updatedDialog, cmd := m.dialog.Update(msg)
+	m.dialog = updatedDialog
+	if cmd != nil {
+		*cmds = append(*cmds, cmd)
+	}
+}
+
+// updateFocused updates the focused model (chat or editor) with the given message
+// and appends any resulting commands to the cmds slice.
+func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
+	switch m.state {
+	case uiChat:
+		m.updateChat(msg, cmds)
+	case uiEdit:
+		m.updateEditor(msg, cmds)
+	}
+}
+
+// updateChat updates the chat model with the given message and appends any
+// resulting commands to the cmds slice.
+func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
+	updatedChat, cmd := m.chat.Update(msg)
+	m.chat = updatedChat
+	if cmd != nil {
+		*cmds = append(*cmds, 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)
+	}
+}
+
+// updateLayout updates the layout based on the given terminal width and
+// height given in cells.
+func (m *UI) updateLayout(w, h int) {
+	// The screen area we're working with
+	area := image.Rect(1, 1, w-1, h-1) // -1 for margins
+	helpKeyMap := m.focusedKeyMap()
+	helpHeight := 1
+	if m.showFullHelp {
+		helpHeight = max(1, len(helpKeyMap.FullHelp()))
+	}
+
+	chatRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40))
+	chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(area.Dy()-5-helpHeight))
+	// Add 1 line margin bottom of mainRect
+	chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
+	editRect, helpRect := uv.SplitVertical(editRect, uv.Fixed(5))
+	// Add 1 line margin bottom of footRect
+	editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
+
+	m.layout = layout{
+		area:    area,
+		chat:    chatRect,
+		editor:  editRect,
+		sidebar: sideRect,
+		help:    helpRect,
+	}
+}
+
+// layout defines the positioning of UI elements.
+type layout struct {
+	// area is the overall available area.
+	area uv.Rectangle
+
+	// chat is the area for the chat pane.
+	chat uv.Rectangle
+
+	// editor is the area for the editor pane.
+	editor uv.Rectangle
+
+	// sidebar is the area for the sidebar.
+	sidebar uv.Rectangle
+
+	// help is the area for the help view.
+	help uv.Rectangle
 }

internal/ui/styles/styles.go 🔗

@@ -5,6 +5,7 @@ import (
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/textarea"
 	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/exp/diffview"
 	"github.com/charmbracelet/glamour/v2/ansi"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -30,6 +31,11 @@ const (
 	BorderThick string = "▌"
 )
 
+const (
+	defaultMargin     = 2
+	defaultListIndent = 2
+)
+
 type Styles struct {
 	WindowTooSmall lipgloss.Style
 
@@ -96,20 +102,28 @@ type Styles struct {
 	// Borders
 	BorderFocus lipgloss.Style
 	BorderBlur  lipgloss.Style
+
+	// Editor
+	EditorPromptNormalFocused   lipgloss.Style
+	EditorPromptNormalBlurred   lipgloss.Style
+	EditorPromptYoloIconFocused lipgloss.Style
+	EditorPromptYoloIconBlurred lipgloss.Style
+	EditorPromptYoloDotsFocused lipgloss.Style
+	EditorPromptYoloDotsBlurred lipgloss.Style
 }
 
 func DefaultStyles() Styles {
 	var (
-		// primary   = charmtone.Charple
+		primary   = charmtone.Charple
 		secondary = charmtone.Dolly
-		// tertiary  = charmtone.Bok
+		tertiary  = charmtone.Bok
 		// accent    = charmtone.Zest
 
 		// Backgrounds
 		bgBase        = charmtone.Pepper
 		bgBaseLighter = charmtone.BBQ
 		bgSubtle      = charmtone.Charcoal
-		// bgOverlay     = charmtone.Iron
+		bgOverlay     = charmtone.Iron
 
 		// Foregrounds
 		fgBase      = charmtone.Ash
@@ -119,7 +133,7 @@ func DefaultStyles() Styles {
 		// fgSelected  = charmtone.Salt
 
 		// Borders
-		// border      = charmtone.Charcoal
+		border      = charmtone.Charcoal
 		borderFocus = charmtone.Charple
 
 		// Status
@@ -147,8 +161,334 @@ func DefaultStyles() Styles {
 		// cherry   = charmtone.Cherry
 	)
 
+	base := lipgloss.NewStyle().Foreground(fgBase)
+
 	s := Styles{}
 
+	s.TextInput = textinput.Styles{
+		Focused: textinput.StyleState{
+			Text:        base,
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(tertiary),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Blurred: textinput.StyleState{
+			Text:        base.Foreground(fgMuted),
+			Placeholder: base.Foreground(fgSubtle),
+			Prompt:      base.Foreground(fgMuted),
+			Suggestion:  base.Foreground(fgSubtle),
+		},
+		Cursor: textinput.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBar,
+			Blink: true,
+		},
+	}
+
+	s.TextArea = textarea.Styles{
+		Focused: textarea.StyleState{
+			Base:             base,
+			Text:             base,
+			LineNumber:       base.Foreground(fgSubtle),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgSubtle),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(tertiary),
+		},
+		Blurred: textarea.StyleState{
+			Base:             base,
+			Text:             base.Foreground(fgMuted),
+			LineNumber:       base.Foreground(fgMuted),
+			CursorLine:       base,
+			CursorLineNumber: base.Foreground(fgMuted),
+			Placeholder:      base.Foreground(fgSubtle),
+			Prompt:           base.Foreground(fgMuted),
+		},
+		Cursor: textarea.CursorStyle{
+			Color: secondary,
+			Shape: tea.CursorBar,
+			Blink: true,
+		},
+	}
+
+	s.Markdown = ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				// BlockPrefix: "\n",
+				// BlockSuffix: "\n",
+				Color: stringPtr(charmtone.Smoke.Hex()),
+			},
+			// Margin: uintPtr(defaultMargin),
+		},
+		BlockQuote: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{},
+			Indent:         uintPtr(1),
+			IndentToken:    stringPtr("│ "),
+		},
+		List: ansi.StyleList{
+			LevelIndent: defaultListIndent,
+		},
+		Heading: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BlockSuffix: "\n",
+				Color:       stringPtr(charmtone.Malibu.Hex()),
+				Bold:        boolPtr(true),
+			},
+		},
+		H1: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Zest.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charple.Hex()),
+				Bold:            boolPtr(true),
+			},
+		},
+		H2: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "## ",
+			},
+		},
+		H3: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "### ",
+			},
+		},
+		H4: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "#### ",
+			},
+		},
+		H5: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "##### ",
+			},
+		},
+		H6: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix: "###### ",
+				Color:  stringPtr(charmtone.Guac.Hex()),
+				Bold:   boolPtr(false),
+			},
+		},
+		Strikethrough: ansi.StylePrimitive{
+			CrossedOut: boolPtr(true),
+		},
+		Emph: ansi.StylePrimitive{
+			Italic: boolPtr(true),
+		},
+		Strong: ansi.StylePrimitive{
+			Bold: boolPtr(true),
+		},
+		HorizontalRule: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Charcoal.Hex()),
+			Format: "\n--------\n",
+		},
+		Item: ansi.StylePrimitive{
+			BlockPrefix: "• ",
+		},
+		Enumeration: ansi.StylePrimitive{
+			BlockPrefix: ". ",
+		},
+		Task: ansi.StyleTask{
+			StylePrimitive: ansi.StylePrimitive{},
+			Ticked:         "[✓] ",
+			Unticked:       "[ ] ",
+		},
+		Link: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Zinc.Hex()),
+			Underline: boolPtr(true),
+		},
+		LinkText: ansi.StylePrimitive{
+			Color: stringPtr(charmtone.Guac.Hex()),
+			Bold:  boolPtr(true),
+		},
+		Image: ansi.StylePrimitive{
+			Color:     stringPtr(charmtone.Cheeky.Hex()),
+			Underline: boolPtr(true),
+		},
+		ImageText: ansi.StylePrimitive{
+			Color:  stringPtr(charmtone.Squid.Hex()),
+			Format: "Image: {{.text}} →",
+		},
+		Code: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Prefix:          " ",
+				Suffix:          " ",
+				Color:           stringPtr(charmtone.Coral.Hex()),
+				BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+			},
+		},
+		CodeBlock: ansi.StyleCodeBlock{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Charcoal.Hex()),
+				},
+				Margin: uintPtr(defaultMargin),
+			},
+			Chroma: &ansi.Chroma{
+				Text: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				Error: ansi.StylePrimitive{
+					Color:           stringPtr(charmtone.Butter.Hex()),
+					BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
+				},
+				Comment: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Oyster.Hex()),
+				},
+				CommentPreproc: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bengal.Hex()),
+				},
+				Keyword: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Malibu.Hex()),
+				},
+				KeywordReserved: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordNamespace: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Pony.Hex()),
+				},
+				KeywordType: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guppy.Hex()),
+				},
+				Operator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Salmon.Hex()),
+				},
+				Punctuation: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Zest.Hex()),
+				},
+				Name: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Smoke.Hex()),
+				},
+				NameBuiltin: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cheeky.Hex()),
+				},
+				NameTag: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Mauve.Hex()),
+				},
+				NameAttribute: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Hazy.Hex()),
+				},
+				NameClass: ansi.StylePrimitive{
+					Color:     stringPtr(charmtone.Salt.Hex()),
+					Underline: boolPtr(true),
+					Bold:      boolPtr(true),
+				},
+				NameDecorator: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Citron.Hex()),
+				},
+				NameFunction: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				LiteralNumber: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Julep.Hex()),
+				},
+				LiteralString: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Cumin.Hex()),
+				},
+				LiteralStringEscape: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Bok.Hex()),
+				},
+				GenericDeleted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Coral.Hex()),
+				},
+				GenericEmph: ansi.StylePrimitive{
+					Italic: boolPtr(true),
+				},
+				GenericInserted: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Guac.Hex()),
+				},
+				GenericStrong: ansi.StylePrimitive{
+					Bold: boolPtr(true),
+				},
+				GenericSubheading: ansi.StylePrimitive{
+					Color: stringPtr(charmtone.Squid.Hex()),
+				},
+				Background: ansi.StylePrimitive{
+					BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
+				},
+			},
+		},
+		Table: ansi.StyleTable{
+			StyleBlock: ansi.StyleBlock{
+				StylePrimitive: ansi.StylePrimitive{},
+			},
+		},
+		DefinitionDescription: ansi.StylePrimitive{
+			BlockPrefix: "\n ",
+		},
+	}
+
+	s.Help = help.Styles{
+		ShortKey:       base.Foreground(fgMuted),
+		ShortDesc:      base.Foreground(fgSubtle),
+		ShortSeparator: base.Foreground(border),
+		Ellipsis:       base.Foreground(border),
+		FullKey:        base.Foreground(fgMuted),
+		FullDesc:       base.Foreground(fgSubtle),
+		FullSeparator:  base.Foreground(border),
+	}
+
+	s.Diff = diffview.Style{
+		DividerLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Foreground(fgHalfMuted).
+				Background(bgBaseLighter),
+		},
+		MissingLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+			Code: lipgloss.NewStyle().
+				Background(bgBaseLighter),
+		},
+		EqualLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+			Code: lipgloss.NewStyle().
+				Foreground(fgMuted).
+				Background(bgBase),
+		},
+		InsertLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#2b322a")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#629657")).
+				Background(lipgloss.Color("#323931")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#323931")),
+		},
+		DeleteLine: diffview.LineStyle{
+			LineNumber: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#312929")),
+			Symbol: lipgloss.NewStyle().
+				Foreground(lipgloss.Color("#a45c59")).
+				Background(lipgloss.Color("#383030")),
+			Code: lipgloss.NewStyle().
+				Background(lipgloss.Color("#383030")),
+		},
+	}
+
+	s.FilePicker = filepicker.Styles{
+		DisabledCursor:   base.Foreground(fgMuted),
+		Cursor:           base.Foreground(fgBase),
+		Symlink:          base.Foreground(fgSubtle),
+		Directory:        base.Foreground(primary),
+		File:             base.Foreground(fgBase),
+		DisabledFile:     base.Foreground(fgMuted),
+		DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted),
+		Permission:       base.Foreground(fgMuted),
+		Selected:         base.Background(primary).Foreground(fgBase),
+		FileSize:         base.Foreground(fgMuted),
+		EmptyDirectory:   base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"),
+	}
+
 	// borders
 	s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
 
@@ -190,5 +530,18 @@ func DefaultStyles() Styles {
 	// Borders
 	s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
 
+	// Editor
+	s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ")
+	s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted)
+	s.EditorPromptYoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
+	s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
+	s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
+	s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
+
 	return s
 }
+
+// Helper functions for style pointers
+func boolPtr(b bool) *bool       { return &b }
+func stringPtr(s string) *string { return &s }
+func uintPtr(u uint) *uint       { return &u }