@@ -8,8 +8,10 @@ import (
9 "charm.land/bubbles/v2/help"
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
10 "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textarea"
11 tea "charm.land/bubbletea/v2"
12 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/common"
14 "github.com/charmbracelet/crush/internal/ui/dialog"
16 "github.com/charmbracelet/crush/internal/ui/dialog"
15 uv "github.com/charmbracelet/ultraviolet"
17 uv "github.com/charmbracelet/ultraviolet"
@@ -20,20 +22,21 @@ type uiState uint8
21// Possible uiState values.
23// Possible uiState values.
23− uiChat uiState = iota
25+ uiEdit uiState = iota
27// UI represents the main user interface model.
29// UI represents the main user interface model.
28type UI struct {
30type UI struct {
29− com *common.Common
31+ com *common.Common
32+ sess *session.Session
31 state uiState
34 state uiState
33 keyMap KeyMap
36 keyMap KeyMap
37+ keyenh tea.KeyboardEnhancementsMsg
35 chat *ChatModel
39 chat *ChatModel
37 side *SidebarModel
40 side *SidebarModel
38 dialog *dialog.Overlay
41 dialog *dialog.Overlay
39 help help.Model
42 help help.Model
@@ -47,18 +50,40 @@ type UI struct {
47 // QueryVersion instructs the TUI to query for the terminal version when it
50 // QueryVersion instructs the TUI to query for the terminal version when it
48 // starts.
51 // starts.
49 QueryVersion bool
52 QueryVersion bool
55+ textarea textarea.Model
57+ attachments []any // TODO: Implement attachments
59+ readyPlaceholder string
60+ workingPlaceholder string
52// New creates a new instance of the [UI] model.
63// New creates a new instance of the [UI] model.
53func New(com *common.Common) *UI {
64func New(com *common.Common) *UI {
54− return &UI{
65+ // Editor components
67+ ta.SetStyles(com.Styles.TextArea)
68+ ta.ShowLineNumbers = false
70+ ta.SetVirtualCursor(false)
55− com: com,
74+ com: com,
56− dialog: dialog.NewOverlay(),
75+ dialog: dialog.NewOverlay(),
57− keyMap: DefaultKeyMap(),
76+ keyMap: DefaultKeyMap(),
58− editor: NewEditorModel(com),
59− side: NewSidebarModel(com),
77+ side: NewSidebarModel(com),
60− help: help.New(),
78+ help: help.New(),
83+ ui.randomizePlaceholders()
84+ ui.textarea.Placeholder = ui.readyPlaceholder
64// Init initializes the UI model.
89// Init initializes the UI model.
@@ -73,6 +98,7 @@ func (m *UI) Init() tea.Cmd {
73// Update handles updates to the UI model.
98// Update handles updates to the UI model.
74func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
75 var cmds []tea.Cmd
100 var cmds []tea.Cmd
101+ hasDialogs := m.dialog.HasDialogs()
76 switch msg := msg.(type) {
102 switch msg := msg.(type) {
77 case tea.EnvMsg:
103 case tea.EnvMsg:
78 // Is this Windows Terminal?
104 // Is this Windows Terminal?
@@ -88,18 +114,30 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 return m, nil
114 return m, nil
89 case tea.WindowSizeMsg:
115 case tea.WindowSizeMsg:
90 m.updateLayoutAndSize(msg.Width, msg.Height)
116 m.updateLayoutAndSize(msg.Width, msg.Height)
117+ case tea.KeyboardEnhancementsMsg:
119+ if msg.SupportsKeyDisambiguation() {
120+ m.keyMap.Models.SetHelp("ctrl+m", "models")
121+ m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
91 case tea.KeyPressMsg:
123 case tea.KeyPressMsg:
92− if m.dialog.HasDialogs() {
124+ if hasDialogs {
93 m.updateDialogs(msg, &cmds)
125 m.updateDialogs(msg, &cmds)
130+ // This branch only handles UI elements when there's no dialog shown.
131+ switch msg := msg.(type) {
132+ case tea.KeyPressMsg:
96 case key.Matches(msg, m.keyMap.Tab):
134 case key.Matches(msg, m.keyMap.Tab):
97 if m.state == uiChat {
135 if m.state == uiChat {
98 m.state = uiEdit
136 m.state = uiEdit
99− cmds = append(cmds, m.editor.Focus())
137+ cmds = append(cmds, m.textarea.Focus())
101 m.state = uiChat
139 m.state = uiChat
102− cmds = append(cmds, m.editor.Blur())
140+ m.textarea.Blur()
104 case key.Matches(msg, m.keyMap.Help):
142 case key.Matches(msg, m.keyMap.Help):
105 m.help.ShowAll = !m.help.ShowAll
143 m.help.ShowAll = !m.help.ShowAll
@@ -109,10 +147,31 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109 m.dialog.AddDialog(dialog.NewQuit(m.com))
147 m.dialog.AddDialog(dialog.NewQuit(m.com))
110 return m, nil
148 return m, nil
150+ case key.Matches(msg, m.keyMap.Commands):
151+ // TODO: Implement me
152+ case key.Matches(msg, m.keyMap.Models):
153+ // TODO: Implement me
154+ case key.Matches(msg, m.keyMap.Sessions):
155+ // TODO: Implement me
113 m.updateFocused(msg, &cmds)
157 m.updateFocused(msg, &cmds)
161+ // This logic gets triggered on any message type, but should it?
165+ // Textarea placeholder logic
166+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167+ m.textarea.Placeholder = m.workingPlaceholder
169+ m.textarea.Placeholder = m.readyPlaceholder
171+ if m.com.App.Permissions.SkipRequests() {
172+ m.textarea.Placeholder = "Yolo mode!"
118 return m, tea.Batch(cmds...)
177 return m, tea.Batch(cmds...)
@@ -126,7 +185,7 @@ func (m *UI) View() tea.View {
126 layers := []*lipgloss.Layer{}
185 layers := []*lipgloss.Layer{}
128 // Determine the help key map based on focus
187 // Determine the help key map based on focus
129− helpKeyMap := m.focusedKeyMap()
188+ var helpKeyMap help.KeyMap = m
131 // The screen areas we're working with
190 // The screen areas we're working with
132 area := m.layout.area
191 area := m.layout.area
@@ -137,11 +196,6 @@ func (m *UI) View() tea.View {
138 if m.dialog.HasDialogs() {
197 if m.dialog.HasDialogs() {
139 if dialogView := m.dialog.View(); dialogView != "" {
198 if dialogView := m.dialog.View(); dialogView != "" {
140− // If the dialog has its own help, use that instead
141− if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 {
142− helpKeyMap = m.dialog
145 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
199 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
146 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
200 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
147 layers = append(layers,
201 layers = append(layers,
@@ -153,8 +207,8 @@ func (m *UI) View() tea.View {
156− if m.state == uiEdit && m.editor.Focused() {
210+ if m.state == uiEdit && m.textarea.Focused() {
157− cur := m.editor.Cursor()
211+ cur := m.textarea.Cursor()
158 cur.X++ // Adjust for app margins
212 cur.X++ // Adjust for app margins
159 cur.Y += editRect.Min.Y
213 cur.Y += editRect.Min.Y
160 v.Cursor = cur
214 v.Cursor = cur
@@ -171,7 +225,7 @@ func (m *UI) View() tea.View {
171 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
225 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
172 lipgloss.NewLayer(m.side.View()).
226 lipgloss.NewLayer(m.side.View()).
173 X(sideRect.Min.X).Y(sideRect.Min.Y),
227 X(sideRect.Min.X).Y(sideRect.Min.Y),
174− lipgloss.NewLayer(m.editor.View()).
228+ lipgloss.NewLayer(m.textarea.View()).
175 X(editRect.Min.X).Y(editRect.Min.Y),
229 X(editRect.Min.X).Y(editRect.Min.Y),
176 lipgloss.NewLayer(m.help.View(helpKeyMap)).
230 lipgloss.NewLayer(m.help.View(helpKeyMap)).
177 X(helpRect.Min.X).Y(helpRect.Min.Y),
231 X(helpRect.Min.X).Y(helpRect.Min.Y),
@@ -189,11 +243,82 @@ func (m *UI) View() tea.View {
192−func (m *UI) focusedKeyMap() help.KeyMap {
246+// ShortHelp implements [help.KeyMap].
193− if m.state == uiChat {
247+func (m *UI) ShortHelp() []key.Binding {
194− return m.chat
248+ var binds []key.Binding
252+ // no session selected
253+ binds = append(binds,
261+ // we have a session
267+ // binds = append(binds,
268+ // k.Editor.AddFile,
269+ // k.Editor.SendMessage,
270+ // k.Editor.OpenEditor,
271+ // k.Editor.Newline,
274+ // if len(m.attachments) > 0 {
275+ // binds = append(binds,
276+ // k.Editor.AttachmentDeleteMode,
277+ // k.Editor.DeleteAllAttachments,
196− return m.editor
283+ return binds
286+// FullHelp implements [help.KeyMap].
287+func (m *UI) FullHelp() [][]key.Binding {
288+ var binds [][]key.Binding
291+ help.SetHelp("ctrl+g", "less")
294+ // no session selected
295+ binds = append(binds,
304+ k.Editor.MentionFile,
305+ k.Editor.OpenEditor,
312+ // we have a session
318+ // binds = append(binds, m.ShortHelp())
199// updateDialogs updates the dialog overlay with the given message and appends
324// updateDialogs updates the dialog overlay with the given message and appends
@@ -213,7 +338,16 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
213 case uiChat:
338 case uiChat:
214 m.updateChat(msg, cmds)
339 m.updateChat(msg, cmds)
215 case uiEdit:
340 case uiEdit:
216− m.updateEditor(msg, cmds)
341+ switch {
342+ case key.Matches(msg, m.keyMap.Editor.Newline):
343+ m.textarea.InsertRune('\n')
346+ ta, cmd := m.textarea.Update(msg)
349+ *cmds = append(*cmds, cmd)
@@ -227,26 +361,13 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
230−// updateEditor updates the editor model with the given message and appends any
231−// resulting commands to the cmds slice.
232−func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
233− updatedEditor, cmd := m.editor.Update(msg)
234− m.editor = updatedEditor
236− *cmds = append(*cmds, cmd)
240// updateLayoutAndSize updates the layout and sub-models sizes based on the
364// updateLayoutAndSize updates the layout and sub-models sizes based on the
241// given terminal width and height given in cells.
365// given terminal width and height given in cells.
242func (m *UI) updateLayoutAndSize(w, h int) {
366func (m *UI) updateLayoutAndSize(w, h int) {
243 // The screen area we're working with
367 // The screen area we're working with
244 area := image.Rect(0, 0, w, h)
368 area := image.Rect(0, 0, w, h)
245− helpKeyMap := m.focusedKeyMap()
369+ var helpKeyMap help.KeyMap = m
246 helpHeight := 1
370 helpHeight := 1
247− if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
248− helpKeyMap = m.dialog
250 if m.help.ShowAll {
371 if m.help.ShowAll {
251 for _, row := range helpKeyMap.FullHelp() {
372 for _, row := range helpKeyMap.FullHelp() {
252 helpHeight = max(helpHeight, len(row))
373 helpHeight = max(helpHeight, len(row))
@@ -280,8 +401,9 @@ func (m *UI) updateLayoutAndSize(w, h int) {
281 // Update sub-model sizes
402 // Update sub-model sizes
282 m.side.SetWidth(m.layout.sidebar.Dx())
403 m.side.SetWidth(m.layout.sidebar.Dx())
283− m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
404+ m.textarea.SetWidth(m.layout.editor.Dx())
405+ m.textarea.SetHeight(m.layout.editor.Dy())
284− m.help.Width = m.layout.help.Dx()
406+ m.help.SetWidth(m.layout.help.Dx())
287// layout defines the positioning of UI elements.
409// layout defines the positioning of UI elements.
@@ -304,3 +426,58 @@ type layout struct {
304 // help is the area for the help view.
426 // help is the area for the help view.
305 help uv.Rectangle
427 help uv.Rectangle
430+func (m *UI) setEditorPrompt() {
431+ if m.com.App.Permissions.SkipRequests() {
432+ m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
435+ m.textarea.SetPromptFunc(4, m.normalPromptFunc)
438+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
440+ if info.LineNumber == 0 {
444+ return t.EditorPromptNormalFocused.Render()
446+ return t.EditorPromptNormalBlurred.Render()
449+func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
451+ if info.LineNumber == 0 {
453+ return t.EditorPromptYoloIconFocused.Render()
455+ return t.EditorPromptYoloIconBlurred.Render()
459+ return t.EditorPromptYoloDotsFocused.Render()
461+ return t.EditorPromptYoloDotsBlurred.Render()
464+var readyPlaceholders = [...]string{
468+ "Ready for instructions",
471+var workingPlaceholders = [...]string{
480+func (m *UI) randomizePlaceholders() {
481+ m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482+ m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]