on main help sign in
feat(ui): add chat and editor models with keybindings
commit7d9976252d289b3331376ff1e0aa3d597736d708 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 28 Oct 2025, 17:56 (1 year ago) parent8783795
9 files changed+855−55
internal/ui/common/common.go+15 internal/ui/common/interface.go+11 internal/ui/dialog/dialog.go+7−2 internal/ui/dialog/quit.go+4−1 internal/ui/model/chat.go+86 internal/ui/model/editor.go+201 internal/ui/model/keys.go+5 internal/ui/model/ui.go+169−48 internal/ui/styles/styles.go+357−4
internal/ui/common/common.go+15
@@ -1,8 +1,11 @@
11package common
22
33import (
4+ "image"
5+
46 "github.com/charmbracelet/crush/internal/config"
57 "github.com/charmbracelet/crush/internal/ui/styles"
8+ uv "github.com/charmbracelet/ultraviolet"
69)
710
811// Common defines common UI options and configurations.
@@ -18,3 +21,15 @@ func DefaultCommon(cfg *config.Config) *Common {
1821 Styles: styles.DefaultStyles(),
1922 }
2023}
24+
25+// CenterRect returns a new [Rectangle] centered within the given area with the
26+// specified width and height.
27+func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
28+ centerX := area.Min.X + area.Dx()/2
29+ centerY := area.Min.Y + area.Dy()/2
30+ minX := centerX - width/2
31+ minY := centerY - height/2
32+ maxX := minX + width
33+ maxY := minY + height
34+ return image.Rect(minX, minY, maxX, maxY)
35+}
internal/ui/common/interface.gonew file+11
@@ -0,0 +1,11 @@
1+package common
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+)
6+
7+// Model represents a common interface for UI components.
8+type Model[T any] interface {
9+ Update(msg tea.Msg) (T, tea.Cmd)
10+ View() string
11+}
internal/ui/dialog/dialog.go+7−2
@@ -3,7 +3,7 @@ package dialog
33import (
44 "github.com/charmbracelet/bubbles/v2/key"
55 tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/ui/component"
6+ "github.com/charmbracelet/crush/internal/ui/common"
77 "github.com/charmbracelet/lipgloss/v2"
88)
99
@@ -23,7 +23,7 @@ func DefaultOverlayKeyMap() OverlayKeyMap {
2323
2424// Dialog is a component that can be displayed on top of the UI.
2525type Dialog interface {
26− component.Model[Dialog]
26+ common.Model[Dialog]
2727 ID() string
2828}
2929
@@ -41,6 +41,11 @@ func NewOverlay(dialogs ...Dialog) *Overlay {
4141 }
4242}
4343
44+// HasDialogs checks if there are any active dialogs.
45+func (d *Overlay) HasDialogs() bool {
46+ return len(d.dialogs) > 0
47+}
48+
4449// ContainsDialog checks if a dialog with the specified ID exists.
4550func (d *Overlay) ContainsDialog(dialogID string) bool {
4651 for _, dialog := range d.dialogs {
internal/ui/dialog/quit.go+4−1
@@ -7,6 +7,9 @@ import (
77 "github.com/charmbracelet/lipgloss/v2"
88)
99
10+// QuitDialogID is the identifier for the quit dialog.
11+const QuitDialogID = "quit"
12+
1013// QuitDialogKeyMap represents key bindings for the quit dialog.
1114type QuitDialogKeyMap struct {
1215 LeftRight,
@@ -65,7 +68,7 @@ func NewQuit(com *common.Common) *Quit {
6568
6669// ID implements [Model].
6770func (*Quit) ID() string {
68− return "quit"
71+ return QuitDialogID
6972}
7073
7174// Update implements [Model].
internal/ui/model/chat.gonew file+86
@@ -0,0 +1,86 @@
1+package model
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/app"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+)
9+
10+// ChatKeyMap defines key bindings for the chat model.
11+type ChatKeyMap struct {
12+ NewSession key.Binding
13+ AddAttachment key.Binding
14+ Cancel key.Binding
15+ Tab key.Binding
16+ Details key.Binding
17+}
18+
19+// DefaultChatKeyMap returns the default key bindings for the chat model.
20+func DefaultChatKeyMap() ChatKeyMap {
21+ return ChatKeyMap{
22+ NewSession: key.NewBinding(
23+ key.WithKeys("ctrl+n"),
24+ key.WithHelp("ctrl+n", "new session"),
25+ ),
26+ AddAttachment: key.NewBinding(
27+ key.WithKeys("ctrl+f"),
28+ key.WithHelp("ctrl+f", "add attachment"),
29+ ),
30+ Cancel: key.NewBinding(
31+ key.WithKeys("esc", "alt+esc"),
32+ key.WithHelp("esc", "cancel"),
33+ ),
34+ Tab: key.NewBinding(
35+ key.WithKeys("tab"),
36+ key.WithHelp("tab", "change focus"),
37+ ),
38+ Details: key.NewBinding(
39+ key.WithKeys("ctrl+d"),
40+ key.WithHelp("ctrl+d", "toggle details"),
41+ ),
42+ }
43+}
44+
45+// ChatModel represents the chat UI model.
46+type ChatModel struct {
47+ app *app.App
48+ com *common.Common
49+
50+ keyMap ChatKeyMap
51+}
52+
53+// NewChatModel creates a new instance of ChatModel.
54+func NewChatModel(com *common.Common, app *app.App) *ChatModel {
55+ return &ChatModel{
56+ app: app,
57+ com: com,
58+ keyMap: DefaultChatKeyMap(),
59+ }
60+}
61+
62+// Init initializes the chat model.
63+func (m *ChatModel) Init() tea.Cmd {
64+ return nil
65+}
66+
67+// Update handles incoming messages and updates the chat model state.
68+func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) {
69+ // Handle messages here
70+ return m, nil
71+}
72+
73+// View renders the chat model's view.
74+func (m *ChatModel) View() string {
75+ return "Chat Model View"
76+}
77+
78+// ShortHelp returns a brief help view for the chat model.
79+func (m *ChatModel) ShortHelp() []key.Binding {
80+ return nil
81+}
82+
83+// FullHelp returns a detailed help view for the chat model.
84+func (m *ChatModel) FullHelp() [][]key.Binding {
85+ return nil
86+}
internal/ui/model/editor.gonew file+201
@@ -0,0 +1,201 @@
1+package model
2+
3+import (
4+ "math/rand"
5+
6+ "github.com/charmbracelet/bubbles/v2/key"
7+ "github.com/charmbracelet/bubbles/v2/textarea"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/app"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+)
12+
13+type EditorKeyMap struct {
14+ AddFile key.Binding
15+ SendMessage key.Binding
16+ OpenEditor key.Binding
17+ Newline key.Binding
18+}
19+
20+func DefaultEditorKeyMap() EditorKeyMap {
21+ return EditorKeyMap{
22+ AddFile: key.NewBinding(
23+ key.WithKeys("/"),
24+ key.WithHelp("/", "add file"),
25+ ),
26+ SendMessage: key.NewBinding(
27+ key.WithKeys("enter"),
28+ key.WithHelp("enter", "send"),
29+ ),
30+ OpenEditor: key.NewBinding(
31+ key.WithKeys("ctrl+o"),
32+ key.WithHelp("ctrl+o", "open editor"),
33+ ),
34+ Newline: key.NewBinding(
35+ key.WithKeys("shift+enter", "ctrl+j"),
36+ // "ctrl+j" is a common keybinding for newline in many editors. If
37+ // the terminal supports "shift+enter", we substitute the help text
38+ // to reflect that.
39+ key.WithHelp("ctrl+j", "newline"),
40+ ),
41+ }
42+}
43+
44+// EditorModel represents the editor UI model.
45+type EditorModel struct {
46+ com *common.Common
47+ app *app.App
48+
49+ keyMap EditorKeyMap
50+ textarea *textarea.Model
51+
52+ readyPlaceholder string
53+ workingPlaceholder string
54+}
55+
56+// NewEditorModel creates a new instance of EditorModel.
57+func NewEditorModel(com *common.Common, app *app.App) *EditorModel {
58+ ta := textarea.New()
59+ ta.SetStyles(com.Styles.TextArea)
60+ ta.ShowLineNumbers = false
61+ ta.CharLimit = -1
62+ ta.SetVirtualCursor(false)
63+ ta.Focus()
64+ e := &EditorModel{
65+ com: com,
66+ app: app,
67+ keyMap: DefaultEditorKeyMap(),
68+ textarea: ta,
69+ }
70+
71+ e.setEditorPrompt()
72+ e.randomizePlaceholders()
73+ e.textarea.Placeholder = e.readyPlaceholder
74+
75+ return e
76+}
77+
78+// Init initializes the editor model.
79+func (m *EditorModel) Init() tea.Cmd {
80+ return nil
81+}
82+
83+// Update handles updates to the editor model.
84+func (m *EditorModel) Update(msg tea.Msg) (*EditorModel, tea.Cmd) {
85+ var cmds []tea.Cmd
86+ var cmd tea.Cmd
87+
88+ m.textarea, cmd = m.textarea.Update(msg)
89+ cmds = append(cmds, cmd)
90+
91+ // Textarea placeholder logic
92+ if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
93+ m.textarea.Placeholder = m.workingPlaceholder
94+ } else {
95+ m.textarea.Placeholder = m.readyPlaceholder
96+ }
97+ if m.app.Permissions.SkipRequests() {
98+ m.textarea.Placeholder = "Yolo mode!"
99+ }
100+
101+ // TODO: Add attachments
102+
103+ return m, tea.Batch(cmds...)
104+}
105+
106+// View renders the editor model.
107+func (m *EditorModel) View() string {
108+ return m.textarea.View()
109+}
110+
111+// ShortHelp returns the short help view for the editor model.
112+func (m *EditorModel) ShortHelp() []key.Binding {
113+ return nil
114+}
115+
116+// FullHelp returns the full help view for the editor model.
117+func (m *EditorModel) FullHelp() [][]key.Binding {
118+ return nil
119+}
120+
121+// Cursor returns the relative cursor position of the editor.
122+func (m *EditorModel) Cursor() *tea.Cursor {
123+ return m.textarea.Cursor()
124+}
125+
126+// Blur implements Container.
127+func (c *EditorModel) Blur() tea.Cmd {
128+ c.textarea.Blur()
129+ return nil
130+}
131+
132+// Focus implements Container.
133+func (c *EditorModel) Focus() tea.Cmd {
134+ return c.textarea.Focus()
135+}
136+
137+// Focused returns whether the editor is focused.
138+func (c *EditorModel) Focused() bool {
139+ return c.textarea.Focused()
140+}
141+
142+// SetSize sets the size of the editor.
143+func (m *EditorModel) SetSize(width, height int) {
144+ m.textarea.SetWidth(width)
145+ m.textarea.SetHeight(height)
146+}
147+
148+func (m *EditorModel) setEditorPrompt() {
149+ if m.app.Permissions.SkipRequests() {
150+ m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
151+ return
152+ }
153+ m.textarea.SetPromptFunc(4, m.normalPromptFunc)
154+}
155+
156+func (m *EditorModel) normalPromptFunc(info textarea.PromptInfo) string {
157+ t := m.com.Styles
158+ if info.LineNumber == 0 {
159+ return " > "
160+ }
161+ if info.Focused {
162+ return t.EditorPromptNormalFocused.Render()
163+ }
164+ return t.EditorPromptNormalBlurred.Render()
165+}
166+
167+func (m *EditorModel) yoloPromptFunc(info textarea.PromptInfo) string {
168+ t := m.com.Styles
169+ if info.LineNumber == 0 {
170+ if info.Focused {
171+ return t.EditorPromptYoloIconFocused.Render()
172+ } else {
173+ return t.EditorPromptYoloIconBlurred.Render()
174+ }
175+ }
176+ if info.Focused {
177+ return t.EditorPromptYoloDotsFocused.Render()
178+ }
179+ return t.EditorPromptYoloDotsBlurred.Render()
180+}
181+
182+var readyPlaceholders = [...]string{
183+ "Ready!",
184+ "Ready...",
185+ "Ready?",
186+ "Ready for instructions",
187+}
188+
189+var workingPlaceholders = [...]string{
190+ "Working!",
191+ "Working...",
192+ "Brrrrr...",
193+ "Prrrrrrrr...",
194+ "Processing...",
195+ "Thinking...",
196+}
197+
198+func (m *EditorModel) randomizePlaceholders() {
199+ m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
200+ m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
201+}
internal/ui/model/keys.go+5
@@ -8,6 +8,7 @@ type KeyMap struct {
88 Commands key.Binding
99 Suspend key.Binding
1010 Sessions key.Binding
11+ Tab key.Binding
1112}
1213
1314func DefaultKeyMap() KeyMap {
@@ -32,5 +33,9 @@ func DefaultKeyMap() KeyMap {
3233 key.WithKeys("ctrl+s"),
3334 key.WithHelp("ctrl+s", "sessions"),
3435 ),
36+ Tab: key.NewBinding(
37+ key.WithKeys("tab"),
38+ key.WithHelp("tab", "change focus"),
39+ ),
3540 }
3641}
internal/ui/model/ui.go+169−48
@@ -2,7 +2,9 @@ package model
22
33import (
44 "image"
5+ "math/rand"
56
7+ "github.com/charmbracelet/bubbles/v2/help"
68 "github.com/charmbracelet/bubbles/v2/key"
79 tea "github.com/charmbracelet/bubbletea/v2"
810 "github.com/charmbracelet/crush/internal/app"
@@ -15,19 +17,25 @@ import (
1517type uiState uint8
1618
1719const (
18− uiStateMain uiState = iota
20+ uiChat uiState = iota
21+ uiEdit
1922)
2023
2124type UI struct {
2225 app *app.App
2326 com *common.Common
2427
25− width, height int
26− state uiState
28+ state uiState
29+ showFullHelp bool
2730
2831 keyMap KeyMap
2932
33+ chat *ChatModel
34+ editor *EditorModel
3035 dialog *dialog.Overlay
36+ help help.Model
37+
38+ layout layout
3139}
3240
3341func New(com *common.Common, app *app.App) *UI {
@@ -36,6 +44,8 @@ func New(com *common.Common, app *app.App) *UI {
3644 com: com,
3745 dialog: dialog.NewOverlay(),
3846 keyMap: DefaultKeyMap(),
47+ editor: NewEditorModel(com, app),
48+ help: help.New(),
3949 }
4050}
4151
@@ -47,28 +57,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4757 var cmds []tea.Cmd
4858 switch msg := msg.(type) {
4959 case tea.WindowSizeMsg:
50− m.width = msg.Width
51− m.height = msg.Height
60+ m.updateLayout(msg.Width, msg.Height)
61+ m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
5262 case tea.KeyPressMsg:
53− switch m.state {
54− case uiStateMain:
63+ if m.dialog.HasDialogs() {
64+ m.updateDialogs(msg, &cmds)
65+ } else {
5566 switch {
67+ case key.Matches(msg, m.keyMap.Tab):
68+ if m.state == uiChat {
69+ m.state = uiEdit
70+ cmds = append(cmds, m.editor.Focus())
71+ } else {
72+ m.state = uiChat
73+ cmds = append(cmds, m.editor.Blur())
74+ }
75+ case key.Matches(msg, m.keyMap.Help):
76+ m.showFullHelp = !m.showFullHelp
77+ m.help.ShowAll = m.showFullHelp
5678 case key.Matches(msg, m.keyMap.Quit):
57− quitDialog := dialog.NewQuit(m.com)
58− if !m.dialog.ContainsDialog(quitDialog.ID()) {
59− m.dialog.AddDialog(quitDialog)
79+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
80+ m.dialog.AddDialog(dialog.NewQuit(m.com))
6081 return m, nil
6182 }
83+ default:
84+ m.updateFocused(msg, &cmds)
6285 }
6386 }
6487 }
6588
66− updatedDialog, cmd := m.dialog.Update(msg)
67− m.dialog = updatedDialog
68− if cmd != nil {
69− cmds = append(cmds, cmd)
70− }
71−
7289 return m, tea.Batch(cmds...)
7390}
7491
@@ -76,41 +93,63 @@ func (m *UI) View() tea.View {
7693 var v tea.View
7794 v.AltScreen = true
7895
79− // The screen area we're working with
80− area := image.Rect(0, 0, m.width, m.height)
8196 layers := []*lipgloss.Layer{}
8297
83− if dialogView := m.dialog.View(); dialogView != "" {
84− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
85− dialogArea := centerRect(area, dialogWidth, dialogHeight)
86− layers = append(layers,
87− lipgloss.NewLayer(dialogView).
88− X(dialogArea.Min.X).
89− Y(dialogArea.Min.Y),
90− )
98+ // Determine the help key map based on focus
99+ helpKeyMap := m.focusedKeyMap()
100+
101+ // The screen areas we're working with
102+ area := m.layout.area
103+ chatRect := m.layout.chat
104+ sideRect := m.layout.sidebar
105+ editRect := m.layout.editor
106+ helpRect := m.layout.help
107+
108+ if m.dialog.HasDialogs() {
109+ if dialogView := m.dialog.View(); dialogView != "" {
110+ // If the dialog has its own help, use that instead
111+ if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 {
112+ helpKeyMap = m.dialog
113+ }
114+
115+ dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
116+ dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
117+ layers = append(layers,
118+ lipgloss.NewLayer(dialogView).
119+ X(dialogArea.Min.X).
120+ Y(dialogArea.Min.Y).
121+ Z(99),
122+ )
123+ }
91124 }
92125
93− mainRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40))
94− mainRect, footRect := uv.SplitVertical(mainRect, uv.Fixed(area.Dy()-7))
126+ if m.state == uiEdit && m.editor.Focused() {
127+ cur := m.editor.Cursor()
128+ cur.X++ // Adjust for app margins
129+ cur.Y += editRect.Min.Y
130+ v.Cursor = cur
131+ }
95132
96133 layers = append(layers, lipgloss.NewLayer(
97− lipgloss.NewStyle().Width(mainRect.Dx()).
98− Height(mainRect.Dy()).
99− Border(lipgloss.NormalBorder()).
134+ lipgloss.NewStyle().Width(chatRect.Dx()).
135+ Height(chatRect.Dy()).
136+ Background(lipgloss.ANSIColor(rand.Intn(256))).
100137 Render(" Main View "),
101− ).X(mainRect.Min.X).Y(mainRect.Min.Y),
138+ ).X(chatRect.Min.X).Y(chatRect.Min.Y),
102139 lipgloss.NewLayer(
103140 lipgloss.NewStyle().Width(sideRect.Dx()).
104141 Height(sideRect.Dy()).
105− Border(lipgloss.NormalBorder()).
142+ Background(lipgloss.ANSIColor(rand.Intn(256))).
106143 Render(" Side View "),
107144 ).X(sideRect.Min.X).Y(sideRect.Min.Y),
145+ lipgloss.NewLayer(m.editor.View()).
146+ X(editRect.Min.X).Y(editRect.Min.Y),
108147 lipgloss.NewLayer(
109− lipgloss.NewStyle().Width(footRect.Dx()).
110− Height(footRect.Dy()).
111− Border(lipgloss.NormalBorder()).
112− Render(" Footer View "),
113− ).X(footRect.Min.X).Y(footRect.Min.Y),
148+ lipgloss.NewStyle().Width(helpRect.Dx()).
149+ Height(helpRect.Dy()).
150+ Background(lipgloss.ANSIColor(rand.Intn(256))).
151+ Render(m.help.View(helpKeyMap)),
152+ ).X(helpRect.Min.X).Y(helpRect.Min.Y),
114153 )
115154
116155 v.Layer = lipgloss.NewCanvas(layers...)
@@ -118,14 +157,96 @@ func (m *UI) View() tea.View {
118157 return v
119158}
120159
121−// centerRect returns a new [Rectangle] centered within the given area with the
122−// specified width and height.
123−func centerRect(area uv.Rectangle, width, height int) uv.Rectangle {
124− centerX := area.Min.X + area.Dx()/2
125− centerY := area.Min.Y + area.Dy()/2
126− minX := centerX - width/2
127− minY := centerY - height/2
128− maxX := minX + width
129− maxY := minY + height
130− return image.Rect(minX, minY, maxX, maxY)
160+func (m *UI) focusedKeyMap() help.KeyMap {
161+ if m.state == uiChat {
162+ return m.chat
163+ }
164+ return m.editor
165+}
166+
167+// updateDialogs updates the dialog overlay with the given message and appends
168+// any resulting commands to the cmds slice.
169+func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
170+ updatedDialog, cmd := m.dialog.Update(msg)
171+ m.dialog = updatedDialog
172+ if cmd != nil {
173+ *cmds = append(*cmds, cmd)
174+ }
175+}
176+
177+// updateFocused updates the focused model (chat or editor) with the given message
178+// and appends any resulting commands to the cmds slice.
179+func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
180+ switch m.state {
181+ case uiChat:
182+ m.updateChat(msg, cmds)
183+ case uiEdit:
184+ m.updateEditor(msg, cmds)
185+ }
186+}
187+
188+// updateChat updates the chat model with the given message and appends any
189+// resulting commands to the cmds slice.
190+func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
191+ updatedChat, cmd := m.chat.Update(msg)
192+ m.chat = updatedChat
193+ if cmd != nil {
194+ *cmds = append(*cmds, cmd)
195+ }
196+}
197+
198+// updateEditor updates the editor model with the given message and appends any
199+// resulting commands to the cmds slice.
200+func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
201+ updatedEditor, cmd := m.editor.Update(msg)
202+ m.editor = updatedEditor
203+ if cmd != nil {
204+ *cmds = append(*cmds, cmd)
205+ }
206+}
207+
208+// updateLayout updates the layout based on the given terminal width and
209+// height given in cells.
210+func (m *UI) updateLayout(w, h int) {
211+ // The screen area we're working with
212+ area := image.Rect(1, 1, w-1, h-1) // -1 for margins
213+ helpKeyMap := m.focusedKeyMap()
214+ helpHeight := 1
215+ if m.showFullHelp {
216+ helpHeight = max(1, len(helpKeyMap.FullHelp()))
217+ }
218+
219+ chatRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40))
220+ chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(area.Dy()-5-helpHeight))
221+ // Add 1 line margin bottom of mainRect
222+ chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
223+ editRect, helpRect := uv.SplitVertical(editRect, uv.Fixed(5))
224+ // Add 1 line margin bottom of footRect
225+ editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
226+
227+ m.layout = layout{
228+ area: area,
229+ chat: chatRect,
230+ editor: editRect,
231+ sidebar: sideRect,
232+ help: helpRect,
233+ }
234+}
235+
236+// layout defines the positioning of UI elements.
237+type layout struct {
238+ // area is the overall available area.
239+ area uv.Rectangle
240+
241+ // chat is the area for the chat pane.
242+ chat uv.Rectangle
243+
244+ // editor is the area for the editor pane.
245+ editor uv.Rectangle
246+
247+ // sidebar is the area for the sidebar.
248+ sidebar uv.Rectangle
249+
250+ // help is the area for the help view.
251+ help uv.Rectangle
131252}
internal/ui/styles/styles.go+357−4
@@ -5,6 +5,7 @@ import (
55 "github.com/charmbracelet/bubbles/v2/help"
66 "github.com/charmbracelet/bubbles/v2/textarea"
77 "github.com/charmbracelet/bubbles/v2/textinput"
8+ tea "github.com/charmbracelet/bubbletea/v2"
89 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
910 "github.com/charmbracelet/glamour/v2/ansi"
1011 "github.com/charmbracelet/lipgloss/v2"
@@ -30,6 +31,11 @@ const (
3031 BorderThick string = "▌"
3132)
3233
34+const (
35+ defaultMargin = 2
36+ defaultListIndent = 2
37+)
38+
3339type Styles struct {
3440 WindowTooSmall lipgloss.Style
3541
@@ -96,20 +102,28 @@ type Styles struct {
96102 // Borders
97103 BorderFocus lipgloss.Style
98104 BorderBlur lipgloss.Style
105+
106+ // Editor
107+ EditorPromptNormalFocused lipgloss.Style
108+ EditorPromptNormalBlurred lipgloss.Style
109+ EditorPromptYoloIconFocused lipgloss.Style
110+ EditorPromptYoloIconBlurred lipgloss.Style
111+ EditorPromptYoloDotsFocused lipgloss.Style
112+ EditorPromptYoloDotsBlurred lipgloss.Style
99113}
100114
101115func DefaultStyles() Styles {
102116 var (
103− // primary = charmtone.Charple
117+ primary = charmtone.Charple
104118 secondary = charmtone.Dolly
105− // tertiary = charmtone.Bok
119+ tertiary = charmtone.Bok
106120 // accent = charmtone.Zest
107121
108122 // Backgrounds
109123 bgBase = charmtone.Pepper
110124 bgBaseLighter = charmtone.BBQ
111125 bgSubtle = charmtone.Charcoal
112− // bgOverlay = charmtone.Iron
126+ bgOverlay = charmtone.Iron
113127
114128 // Foregrounds
115129 fgBase = charmtone.Ash
@@ -119,7 +133,7 @@ func DefaultStyles() Styles {
119133 // fgSelected = charmtone.Salt
120134
121135 // Borders
122− // border = charmtone.Charcoal
136+ border = charmtone.Charcoal
123137 borderFocus = charmtone.Charple
124138
125139 // Status
@@ -147,8 +161,334 @@ func DefaultStyles() Styles {
147161 // cherry = charmtone.Cherry
148162 )
149163
164+ base := lipgloss.NewStyle().Foreground(fgBase)
165+
150166 s := Styles{}
151167
168+ s.TextInput = textinput.Styles{
169+ Focused: textinput.StyleState{
170+ Text: base,
171+ Placeholder: base.Foreground(fgSubtle),
172+ Prompt: base.Foreground(tertiary),
173+ Suggestion: base.Foreground(fgSubtle),
174+ },
175+ Blurred: textinput.StyleState{
176+ Text: base.Foreground(fgMuted),
177+ Placeholder: base.Foreground(fgSubtle),
178+ Prompt: base.Foreground(fgMuted),
179+ Suggestion: base.Foreground(fgSubtle),
180+ },
181+ Cursor: textinput.CursorStyle{
182+ Color: secondary,
183+ Shape: tea.CursorBar,
184+ Blink: true,
185+ },
186+ }
187+
188+ s.TextArea = textarea.Styles{
189+ Focused: textarea.StyleState{
190+ Base: base,
191+ Text: base,
192+ LineNumber: base.Foreground(fgSubtle),
193+ CursorLine: base,
194+ CursorLineNumber: base.Foreground(fgSubtle),
195+ Placeholder: base.Foreground(fgSubtle),
196+ Prompt: base.Foreground(tertiary),
197+ },
198+ Blurred: textarea.StyleState{
199+ Base: base,
200+ Text: base.Foreground(fgMuted),
201+ LineNumber: base.Foreground(fgMuted),
202+ CursorLine: base,
203+ CursorLineNumber: base.Foreground(fgMuted),
204+ Placeholder: base.Foreground(fgSubtle),
205+ Prompt: base.Foreground(fgMuted),
206+ },
207+ Cursor: textarea.CursorStyle{
208+ Color: secondary,
209+ Shape: tea.CursorBar,
210+ Blink: true,
211+ },
212+ }
213+
214+ s.Markdown = ansi.StyleConfig{
215+ Document: ansi.StyleBlock{
216+ StylePrimitive: ansi.StylePrimitive{
217+ // BlockPrefix: "\n",
218+ // BlockSuffix: "\n",
219+ Color: stringPtr(charmtone.Smoke.Hex()),
220+ },
221+ // Margin: uintPtr(defaultMargin),
222+ },
223+ BlockQuote: ansi.StyleBlock{
224+ StylePrimitive: ansi.StylePrimitive{},
225+ Indent: uintPtr(1),
226+ IndentToken: stringPtr("│ "),
227+ },
228+ List: ansi.StyleList{
229+ LevelIndent: defaultListIndent,
230+ },
231+ Heading: ansi.StyleBlock{
232+ StylePrimitive: ansi.StylePrimitive{
233+ BlockSuffix: "\n",
234+ Color: stringPtr(charmtone.Malibu.Hex()),
235+ Bold: boolPtr(true),
236+ },
237+ },
238+ H1: ansi.StyleBlock{
239+ StylePrimitive: ansi.StylePrimitive{
240+ Prefix: " ",
241+ Suffix: " ",
242+ Color: stringPtr(charmtone.Zest.Hex()),
243+ BackgroundColor: stringPtr(charmtone.Charple.Hex()),
244+ Bold: boolPtr(true),
245+ },
246+ },
247+ H2: ansi.StyleBlock{
248+ StylePrimitive: ansi.StylePrimitive{
249+ Prefix: "## ",
250+ },
251+ },
252+ H3: ansi.StyleBlock{
253+ StylePrimitive: ansi.StylePrimitive{
254+ Prefix: "### ",
255+ },
256+ },
257+ H4: ansi.StyleBlock{
258+ StylePrimitive: ansi.StylePrimitive{
259+ Prefix: "#### ",
260+ },
261+ },
262+ H5: ansi.StyleBlock{
263+ StylePrimitive: ansi.StylePrimitive{
264+ Prefix: "##### ",
265+ },
266+ },
267+ H6: ansi.StyleBlock{
268+ StylePrimitive: ansi.StylePrimitive{
269+ Prefix: "###### ",
270+ Color: stringPtr(charmtone.Guac.Hex()),
271+ Bold: boolPtr(false),
272+ },
273+ },
274+ Strikethrough: ansi.StylePrimitive{
275+ CrossedOut: boolPtr(true),
276+ },
277+ Emph: ansi.StylePrimitive{
278+ Italic: boolPtr(true),
279+ },
280+ Strong: ansi.StylePrimitive{
281+ Bold: boolPtr(true),
282+ },
283+ HorizontalRule: ansi.StylePrimitive{
284+ Color: stringPtr(charmtone.Charcoal.Hex()),
285+ Format: "\n--------\n",
286+ },
287+ Item: ansi.StylePrimitive{
288+ BlockPrefix: "• ",
289+ },
290+ Enumeration: ansi.StylePrimitive{
291+ BlockPrefix: ". ",
292+ },
293+ Task: ansi.StyleTask{
294+ StylePrimitive: ansi.StylePrimitive{},
295+ Ticked: "[✓] ",
296+ Unticked: "[ ] ",
297+ },
298+ Link: ansi.StylePrimitive{
299+ Color: stringPtr(charmtone.Zinc.Hex()),
300+ Underline: boolPtr(true),
301+ },
302+ LinkText: ansi.StylePrimitive{
303+ Color: stringPtr(charmtone.Guac.Hex()),
304+ Bold: boolPtr(true),
305+ },
306+ Image: ansi.StylePrimitive{
307+ Color: stringPtr(charmtone.Cheeky.Hex()),
308+ Underline: boolPtr(true),
309+ },
310+ ImageText: ansi.StylePrimitive{
311+ Color: stringPtr(charmtone.Squid.Hex()),
312+ Format: "Image: {{.text}} →",
313+ },
314+ Code: ansi.StyleBlock{
315+ StylePrimitive: ansi.StylePrimitive{
316+ Prefix: " ",
317+ Suffix: " ",
318+ Color: stringPtr(charmtone.Coral.Hex()),
319+ BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
320+ },
321+ },
322+ CodeBlock: ansi.StyleCodeBlock{
323+ StyleBlock: ansi.StyleBlock{
324+ StylePrimitive: ansi.StylePrimitive{
325+ Color: stringPtr(charmtone.Charcoal.Hex()),
326+ },
327+ Margin: uintPtr(defaultMargin),
328+ },
329+ Chroma: &ansi.Chroma{
330+ Text: ansi.StylePrimitive{
331+ Color: stringPtr(charmtone.Smoke.Hex()),
332+ },
333+ Error: ansi.StylePrimitive{
334+ Color: stringPtr(charmtone.Butter.Hex()),
335+ BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
336+ },
337+ Comment: ansi.StylePrimitive{
338+ Color: stringPtr(charmtone.Oyster.Hex()),
339+ },
340+ CommentPreproc: ansi.StylePrimitive{
341+ Color: stringPtr(charmtone.Bengal.Hex()),
342+ },
343+ Keyword: ansi.StylePrimitive{
344+ Color: stringPtr(charmtone.Malibu.Hex()),
345+ },
346+ KeywordReserved: ansi.StylePrimitive{
347+ Color: stringPtr(charmtone.Pony.Hex()),
348+ },
349+ KeywordNamespace: ansi.StylePrimitive{
350+ Color: stringPtr(charmtone.Pony.Hex()),
351+ },
352+ KeywordType: ansi.StylePrimitive{
353+ Color: stringPtr(charmtone.Guppy.Hex()),
354+ },
355+ Operator: ansi.StylePrimitive{
356+ Color: stringPtr(charmtone.Salmon.Hex()),
357+ },
358+ Punctuation: ansi.StylePrimitive{
359+ Color: stringPtr(charmtone.Zest.Hex()),
360+ },
361+ Name: ansi.StylePrimitive{
362+ Color: stringPtr(charmtone.Smoke.Hex()),
363+ },
364+ NameBuiltin: ansi.StylePrimitive{
365+ Color: stringPtr(charmtone.Cheeky.Hex()),
366+ },
367+ NameTag: ansi.StylePrimitive{
368+ Color: stringPtr(charmtone.Mauve.Hex()),
369+ },
370+ NameAttribute: ansi.StylePrimitive{
371+ Color: stringPtr(charmtone.Hazy.Hex()),
372+ },
373+ NameClass: ansi.StylePrimitive{
374+ Color: stringPtr(charmtone.Salt.Hex()),
375+ Underline: boolPtr(true),
376+ Bold: boolPtr(true),
377+ },
378+ NameDecorator: ansi.StylePrimitive{
379+ Color: stringPtr(charmtone.Citron.Hex()),
380+ },
381+ NameFunction: ansi.StylePrimitive{
382+ Color: stringPtr(charmtone.Guac.Hex()),
383+ },
384+ LiteralNumber: ansi.StylePrimitive{
385+ Color: stringPtr(charmtone.Julep.Hex()),
386+ },
387+ LiteralString: ansi.StylePrimitive{
388+ Color: stringPtr(charmtone.Cumin.Hex()),
389+ },
390+ LiteralStringEscape: ansi.StylePrimitive{
391+ Color: stringPtr(charmtone.Bok.Hex()),
392+ },
393+ GenericDeleted: ansi.StylePrimitive{
394+ Color: stringPtr(charmtone.Coral.Hex()),
395+ },
396+ GenericEmph: ansi.StylePrimitive{
397+ Italic: boolPtr(true),
398+ },
399+ GenericInserted: ansi.StylePrimitive{
400+ Color: stringPtr(charmtone.Guac.Hex()),
401+ },
402+ GenericStrong: ansi.StylePrimitive{
403+ Bold: boolPtr(true),
404+ },
405+ GenericSubheading: ansi.StylePrimitive{
406+ Color: stringPtr(charmtone.Squid.Hex()),
407+ },
408+ Background: ansi.StylePrimitive{
409+ BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
410+ },
411+ },
412+ },
413+ Table: ansi.StyleTable{
414+ StyleBlock: ansi.StyleBlock{
415+ StylePrimitive: ansi.StylePrimitive{},
416+ },
417+ },
418+ DefinitionDescription: ansi.StylePrimitive{
419+ BlockPrefix: "\n ",
420+ },
421+ }
422+
423+ s.Help = help.Styles{
424+ ShortKey: base.Foreground(fgMuted),
425+ ShortDesc: base.Foreground(fgSubtle),
426+ ShortSeparator: base.Foreground(border),
427+ Ellipsis: base.Foreground(border),
428+ FullKey: base.Foreground(fgMuted),
429+ FullDesc: base.Foreground(fgSubtle),
430+ FullSeparator: base.Foreground(border),
431+ }
432+
433+ s.Diff = diffview.Style{
434+ DividerLine: diffview.LineStyle{
435+ LineNumber: lipgloss.NewStyle().
436+ Foreground(fgHalfMuted).
437+ Background(bgBaseLighter),
438+ Code: lipgloss.NewStyle().
439+ Foreground(fgHalfMuted).
440+ Background(bgBaseLighter),
441+ },
442+ MissingLine: diffview.LineStyle{
443+ LineNumber: lipgloss.NewStyle().
444+ Background(bgBaseLighter),
445+ Code: lipgloss.NewStyle().
446+ Background(bgBaseLighter),
447+ },
448+ EqualLine: diffview.LineStyle{
449+ LineNumber: lipgloss.NewStyle().
450+ Foreground(fgMuted).
451+ Background(bgBase),
452+ Code: lipgloss.NewStyle().
453+ Foreground(fgMuted).
454+ Background(bgBase),
455+ },
456+ InsertLine: diffview.LineStyle{
457+ LineNumber: lipgloss.NewStyle().
458+ Foreground(lipgloss.Color("#629657")).
459+ Background(lipgloss.Color("#2b322a")),
460+ Symbol: lipgloss.NewStyle().
461+ Foreground(lipgloss.Color("#629657")).
462+ Background(lipgloss.Color("#323931")),
463+ Code: lipgloss.NewStyle().
464+ Background(lipgloss.Color("#323931")),
465+ },
466+ DeleteLine: diffview.LineStyle{
467+ LineNumber: lipgloss.NewStyle().
468+ Foreground(lipgloss.Color("#a45c59")).
469+ Background(lipgloss.Color("#312929")),
470+ Symbol: lipgloss.NewStyle().
471+ Foreground(lipgloss.Color("#a45c59")).
472+ Background(lipgloss.Color("#383030")),
473+ Code: lipgloss.NewStyle().
474+ Background(lipgloss.Color("#383030")),
475+ },
476+ }
477+
478+ s.FilePicker = filepicker.Styles{
479+ DisabledCursor: base.Foreground(fgMuted),
480+ Cursor: base.Foreground(fgBase),
481+ Symlink: base.Foreground(fgSubtle),
482+ Directory: base.Foreground(primary),
483+ File: base.Foreground(fgBase),
484+ DisabledFile: base.Foreground(fgMuted),
485+ DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted),
486+ Permission: base.Foreground(fgMuted),
487+ Selected: base.Background(primary).Foreground(fgBase),
488+ FileSize: base.Foreground(fgMuted),
489+ EmptyDirectory: base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"),
490+ }
491+
152492 // borders
153493 s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
154494
@@ -190,5 +530,18 @@ func DefaultStyles() Styles {
190530 // Borders
191531 s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
192532
533+ // Editor
534+ s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ")
535+ s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted)
536+ s.EditorPromptYoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
537+ s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
538+ s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
539+ s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
540+
193541 return s
194542}
543+
544+// Helper functions for style pointers
545+func boolPtr(b bool) *bool { return &b }
546+func stringPtr(s string) *string { return &s }
547+func uintPtr(u uint) *uint { return &u }