on main help sign in
feat(ui): add chat and editor models with keybindings
7d99762Ayman Bagabas
internal/ui/common/common.go+15
@@ -1,8 +1,11 @@
1package common
1package common
2
2
3import (
3import (
4+ "image"
5+
4 "github.com/charmbracelet/crush/internal/config"
6 "github.com/charmbracelet/crush/internal/config"
5 "github.com/charmbracelet/crush/internal/ui/styles"
7 "github.com/charmbracelet/crush/internal/ui/styles"
8+ uv "github.com/charmbracelet/ultraviolet"
6)
9)
7
10
8// Common defines common UI options and configurations.
11// Common defines common UI options and configurations.
@@ -18,3 +21,15 @@ func DefaultCommon(cfg *config.Config) *Common {
18 Styles: styles.DefaultStyles(),
21 Styles: styles.DefaultStyles(),
19 }
22 }
20}
23}
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
3import (
3import (
4 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/ui/component"
6+ "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8)
8)
9
9
@@ -23,7 +23,7 @@ func DefaultOverlayKeyMap() OverlayKeyMap {
23
23
24// Dialog is a component that can be displayed on top of the UI.
24// Dialog is a component that can be displayed on top of the UI.
25type Dialog interface {
25type Dialog interface {
26− component.Model[Dialog]
26+ common.Model[Dialog]
27 ID() string
27 ID() string
28}
28}
29
29
@@ -41,6 +41,11 @@ func NewOverlay(dialogs ...Dialog) *Overlay {
41 }
41 }
42}
42}
43
43
44+// HasDialogs checks if there are any active dialogs.
45+func (d *Overlay) HasDialogs() bool {
46+ return len(d.dialogs) > 0
47+}
48+
44// ContainsDialog checks if a dialog with the specified ID exists.
49// ContainsDialog checks if a dialog with the specified ID exists.
45func (d *Overlay) ContainsDialog(dialogID string) bool {
50func (d *Overlay) ContainsDialog(dialogID string) bool {
46 for _, dialog := range d.dialogs {
51 for _, dialog := range d.dialogs {
internal/ui/dialog/quit.go+4−1
@@ -7,6 +7,9 @@ import (
7 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
8)
8)
9
9
10+// QuitDialogID is the identifier for the quit dialog.
11+const QuitDialogID = "quit"
12+
10// QuitDialogKeyMap represents key bindings for the quit dialog.
13// QuitDialogKeyMap represents key bindings for the quit dialog.
11type QuitDialogKeyMap struct {
14type QuitDialogKeyMap struct {
12 LeftRight,
15 LeftRight,
@@ -65,7 +68,7 @@ func NewQuit(com *common.Common) *Quit {
65
68
66// ID implements [Model].
69// ID implements [Model].
67func (*Quit) ID() string {
70func (*Quit) ID() string {
68− return "quit"
71+ return QuitDialogID
69}
72}
70
73
71// Update implements [Model].
74// 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 {
8 Commands key.Binding
8 Commands key.Binding
9 Suspend key.Binding
9 Suspend key.Binding
10 Sessions key.Binding
10 Sessions key.Binding
11+ Tab key.Binding
11}
12}
12
13
13func DefaultKeyMap() KeyMap {
14func DefaultKeyMap() KeyMap {
@@ -32,5 +33,9 @@ func DefaultKeyMap() KeyMap {
32 key.WithKeys("ctrl+s"),
33 key.WithKeys("ctrl+s"),
33 key.WithHelp("ctrl+s", "sessions"),
34 key.WithHelp("ctrl+s", "sessions"),
34 ),
35 ),
36+ Tab: key.NewBinding(
37+ key.WithKeys("tab"),
38+ key.WithHelp("tab", "change focus"),
39+ ),
35 }
40 }
36}
41}
internal/ui/model/ui.go+169−48
@@ -2,7 +2,9 @@ package model
2
2
3import (
3import (
4 "image"
4 "image"
5+ "math/rand"
5
6
7+ "github.com/charmbracelet/bubbles/v2/help"
6 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
@@ -15,19 +17,25 @@ import (
15type uiState uint8
17type uiState uint8
16
18
17const (
19const (
18− uiStateMain uiState = iota
20+ uiChat uiState = iota
21+ uiEdit
19)
22)
20
23
21type UI struct {
24type UI struct {
22 app *app.App
25 app *app.App
23 com *common.Common
26 com *common.Common
24
27
25− width, height int
26− state uiState
28+ state uiState
29+ showFullHelp bool
27
30
28 keyMap KeyMap
31 keyMap KeyMap
29
32
33+ chat *ChatModel
34+ editor *EditorModel
30 dialog *dialog.Overlay
35 dialog *dialog.Overlay
36+ help help.Model
37+
38+ layout layout
31}
39}
32
40
33func New(com *common.Common, app *app.App) *UI {
41func New(com *common.Common, app *app.App) *UI {
@@ -36,6 +44,8 @@ func New(com *common.Common, app *app.App) *UI {
36 com: com,
44 com: com,
37 dialog: dialog.NewOverlay(),
45 dialog: dialog.NewOverlay(),
38 keyMap: DefaultKeyMap(),
46 keyMap: DefaultKeyMap(),
47+ editor: NewEditorModel(com, app),
48+ help: help.New(),
39 }
49 }
40}
50}
41
51
@@ -47,28 +57,35 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 var cmds []tea.Cmd
57 var cmds []tea.Cmd
48 switch msg := msg.(type) {
58 switch msg := msg.(type) {
49 case tea.WindowSizeMsg:
59 case tea.WindowSizeMsg:
50− m.width = msg.Width
60+ m.updateLayout(msg.Width, msg.Height)
51− m.height = msg.Height
61+ m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
52 case tea.KeyPressMsg:
62 case tea.KeyPressMsg:
53− switch m.state {
63+ if m.dialog.HasDialogs() {
54− case uiStateMain:
64+ m.updateDialogs(msg, &cmds)
65+ } else {
55 switch {
66 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
56 case key.Matches(msg, m.keyMap.Quit):
78 case key.Matches(msg, m.keyMap.Quit):
57− quitDialog := dialog.NewQuit(m.com)
58− if !m.dialog.ContainsDialog(quitDialog.ID()) {
79+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
59− m.dialog.AddDialog(quitDialog)
80+ m.dialog.AddDialog(dialog.NewQuit(m.com))
60 return m, nil
81 return m, nil
61 }
82 }
83+ default:
84+ m.updateFocused(msg, &cmds)
62 }
85 }
63 }
86 }
64 }
87 }
65
88
66− updatedDialog, cmd := m.dialog.Update(msg)
67− m.dialog = updatedDialog
68− if cmd != nil {
69− cmds = append(cmds, cmd)
70− }
71−
72 return m, tea.Batch(cmds...)
89 return m, tea.Batch(cmds...)
73}
90}
74
91
@@ -76,41 +93,63 @@ func (m *UI) View() tea.View {
76 var v tea.View
93 var v tea.View
77 v.AltScreen = true
94 v.AltScreen = true
78
95
79− // The screen area we're working with
80− area := image.Rect(0, 0, m.width, m.height)
81 layers := []*lipgloss.Layer{}
96 layers := []*lipgloss.Layer{}
82
97
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() {
83− if dialogView := m.dialog.View(); dialogView != "" {
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+
84− dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
115+ dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
85− dialogArea := centerRect(area, dialogWidth, dialogHeight)
116+ dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
86− layers = append(layers,
117+ layers = append(layers,
87− lipgloss.NewLayer(dialogView).
118+ lipgloss.NewLayer(dialogView).
88− X(dialogArea.Min.X).
119+ X(dialogArea.Min.X).
89− Y(dialogArea.Min.Y),
120+ Y(dialogArea.Min.Y).
90− )
121+ Z(99),
122+ )
123+ }
91 }
124 }
92
125
93− mainRect, sideRect := uv.SplitHorizontal(area, uv.Fixed(area.Dx()-40))
126+ if m.state == uiEdit && m.editor.Focused() {
94− mainRect, footRect := uv.SplitVertical(mainRect, uv.Fixed(area.Dy()-7))
127+ cur := m.editor.Cursor()
128+ cur.X++ // Adjust for app margins
129+ cur.Y += editRect.Min.Y
130+ v.Cursor = cur
131+ }
95
132
96 layers = append(layers, lipgloss.NewLayer(
133 layers = append(layers, lipgloss.NewLayer(
97− lipgloss.NewStyle().Width(mainRect.Dx()).
134+ lipgloss.NewStyle().Width(chatRect.Dx()).
98− Height(mainRect.Dy()).
135+ Height(chatRect.Dy()).
99− Border(lipgloss.NormalBorder()).
136+ Background(lipgloss.ANSIColor(rand.Intn(256))).
100 Render(" Main View "),
137 Render(" Main View "),
101− ).X(mainRect.Min.X).Y(mainRect.Min.Y),
138+ ).X(chatRect.Min.X).Y(chatRect.Min.Y),
102 lipgloss.NewLayer(
139 lipgloss.NewLayer(
103 lipgloss.NewStyle().Width(sideRect.Dx()).
140 lipgloss.NewStyle().Width(sideRect.Dx()).
104 Height(sideRect.Dy()).
141 Height(sideRect.Dy()).
105− Border(lipgloss.NormalBorder()).
142+ Background(lipgloss.ANSIColor(rand.Intn(256))).
106 Render(" Side View "),
143 Render(" Side View "),
107 ).X(sideRect.Min.X).Y(sideRect.Min.Y),
144 ).X(sideRect.Min.X).Y(sideRect.Min.Y),
145+ lipgloss.NewLayer(m.editor.View()).
146+ X(editRect.Min.X).Y(editRect.Min.Y),
108 lipgloss.NewLayer(
147 lipgloss.NewLayer(
109− lipgloss.NewStyle().Width(footRect.Dx()).
148+ lipgloss.NewStyle().Width(helpRect.Dx()).
110− Height(footRect.Dy()).
149+ Height(helpRect.Dy()).
111− Border(lipgloss.NormalBorder()).
150+ Background(lipgloss.ANSIColor(rand.Intn(256))).
112− Render(" Footer View "),
151+ Render(m.help.View(helpKeyMap)),
113− ).X(footRect.Min.X).Y(footRect.Min.Y),
152+ ).X(helpRect.Min.X).Y(helpRect.Min.Y),
114 )
153 )
115
154
116 v.Layer = lipgloss.NewCanvas(layers...)
155 v.Layer = lipgloss.NewCanvas(layers...)
@@ -118,14 +157,96 @@ func (m *UI) View() tea.View {
118 return v
157 return v
119}
158}
120
159
121−// centerRect returns a new [Rectangle] centered within the given area with the
160+func (m *UI) focusedKeyMap() help.KeyMap {
122−// specified width and height.
161+ if m.state == uiChat {
123−func centerRect(area uv.Rectangle, width, height int) uv.Rectangle {
162+ return m.chat
124− centerX := area.Min.X + area.Dx()/2
163+ }
125− centerY := area.Min.Y + area.Dy()/2
164+ return m.editor
126− minX := centerX - width/2
165+}
127− minY := centerY - height/2
166+
128− maxX := minX + width
167+// updateDialogs updates the dialog overlay with the given message and appends
129− maxY := minY + height
168+// any resulting commands to the cmds slice.
130− return image.Rect(minX, minY, maxX, maxY)
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
131}
252}
internal/ui/styles/styles.go+357−4
@@ -5,6 +5,7 @@ import (
5 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/help"
6 "github.com/charmbracelet/bubbles/v2/textarea"
6 "github.com/charmbracelet/bubbles/v2/textarea"
7 "github.com/charmbracelet/bubbles/v2/textinput"
7 "github.com/charmbracelet/bubbles/v2/textinput"
8+ tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
9 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
9 "github.com/charmbracelet/glamour/v2/ansi"
10 "github.com/charmbracelet/glamour/v2/ansi"
10 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
@@ -30,6 +31,11 @@ const (
30 BorderThick string = "▌"
31 BorderThick string = "▌"
31)
32)
32
33
34+const (
35+ defaultMargin = 2
36+ defaultListIndent = 2
37+)
38+
33type Styles struct {
39type Styles struct {
34 WindowTooSmall lipgloss.Style
40 WindowTooSmall lipgloss.Style
35
41
@@ -96,20 +102,28 @@ type Styles struct {
96 // Borders
102 // Borders
97 BorderFocus lipgloss.Style
103 BorderFocus lipgloss.Style
98 BorderBlur lipgloss.Style
104 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
99}
113}
100
114
101func DefaultStyles() Styles {
115func DefaultStyles() Styles {
102 var (
116 var (
103− // primary = charmtone.Charple
117+ primary = charmtone.Charple
104 secondary = charmtone.Dolly
118 secondary = charmtone.Dolly
105− // tertiary = charmtone.Bok
119+ tertiary = charmtone.Bok
106 // accent = charmtone.Zest
120 // accent = charmtone.Zest
107
121
108 // Backgrounds
122 // Backgrounds
109 bgBase = charmtone.Pepper
123 bgBase = charmtone.Pepper
110 bgBaseLighter = charmtone.BBQ
124 bgBaseLighter = charmtone.BBQ
111 bgSubtle = charmtone.Charcoal
125 bgSubtle = charmtone.Charcoal
112− // bgOverlay = charmtone.Iron
126+ bgOverlay = charmtone.Iron
113
127
114 // Foregrounds
128 // Foregrounds
115 fgBase = charmtone.Ash
129 fgBase = charmtone.Ash
@@ -119,7 +133,7 @@ func DefaultStyles() Styles {
119 // fgSelected = charmtone.Salt
133 // fgSelected = charmtone.Salt
120
134
121 // Borders
135 // Borders
122− // border = charmtone.Charcoal
136+ border = charmtone.Charcoal
123 borderFocus = charmtone.Charple
137 borderFocus = charmtone.Charple
124
138
125 // Status
139 // Status
@@ -147,8 +161,334 @@ func DefaultStyles() Styles {
147 // cherry = charmtone.Cherry
161 // cherry = charmtone.Cherry
148 )
162 )
149
163
164+ base := lipgloss.NewStyle().Foreground(fgBase)
165+
150 s := Styles{}
166 s := Styles{}
151
167
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+
152 // borders
492 // borders
153 s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
493 s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
154
494
@@ -190,5 +530,18 @@ func DefaultStyles() Styles {
190 // Borders
530 // Borders
191 s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
531 s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
192
532
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+
193 return s
541 return s
194}
542}
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 }