@@ -8,8 +8,10 @@ import (
88
99 "charm.land/bubbles/v2/help"
1010 "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textarea"
1112 tea "charm.land/bubbletea/v2"
1213 "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/crush/internal/session"
1315 "github.com/charmbracelet/crush/internal/ui/common"
1416 "github.com/charmbracelet/crush/internal/ui/dialog"
1517 uv "github.com/charmbracelet/ultraviolet"
@@ -20,20 +22,21 @@ type uiState uint8
2022
2123// Possible uiState values.
2224const (
23− uiChat uiState = iota
24− uiEdit
25+ uiEdit uiState = iota
26+ uiChat
2527)
2628
2729// UI represents the main user interface model.
2830type UI struct {
29− com *common.Common
31+ com *common.Common
32+ sess *session.Session
3033
3134 state uiState
3235
3336 keyMap KeyMap
37+ keyenh tea.KeyboardEnhancementsMsg
3438
3539 chat *ChatModel
36− editor *EditorModel
3740 side *SidebarModel
3841 dialog *dialog.Overlay
3942 help help.Model
@@ -47,18 +50,40 @@ type UI struct {
4750 // QueryVersion instructs the TUI to query for the terminal version when it
4851 // starts.
4952 QueryVersion bool
53+
54+ // Editor components
55+ textarea textarea.Model
56+
57+ attachments []any // TODO: Implement attachments
58+
59+ readyPlaceholder string
60+ workingPlaceholder string
5061}
5162
5263// New creates a new instance of the [UI] model.
5364func New(com *common.Common) *UI {
54− return &UI{
55− com: com,
56− dialog: dialog.NewOverlay(),
57− keyMap: DefaultKeyMap(),
58− editor: NewEditorModel(com),
59− side: NewSidebarModel(com),
60− help: help.New(),
65+ // Editor components
66+ ta := textarea.New()
67+ ta.SetStyles(com.Styles.TextArea)
68+ ta.ShowLineNumbers = false
69+ ta.CharLimit = -1
70+ ta.SetVirtualCursor(false)
71+ ta.Focus()
72+
73+ ui := &UI{
74+ com: com,
75+ dialog: dialog.NewOverlay(),
76+ keyMap: DefaultKeyMap(),
77+ side: NewSidebarModel(com),
78+ help: help.New(),
79+ textarea: ta,
6180 }
81+
82+ ui.setEditorPrompt()
83+ ui.randomizePlaceholders()
84+ ui.textarea.Placeholder = ui.readyPlaceholder
85+
86+ return ui
6287}
6388
6489// Init initializes the UI model.
@@ -73,6 +98,7 @@ func (m *UI) Init() tea.Cmd {
7398// Update handles updates to the UI model.
7499func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
75100 var cmds []tea.Cmd
101+ hasDialogs := m.dialog.HasDialogs()
76102 switch msg := msg.(type) {
77103 case tea.EnvMsg:
78104 // Is this Windows Terminal?
@@ -88,18 +114,30 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88114 return m, nil
89115 case tea.WindowSizeMsg:
90116 m.updateLayoutAndSize(msg.Width, msg.Height)
117+ case tea.KeyboardEnhancementsMsg:
118+ m.keyenh = msg
119+ if msg.SupportsKeyDisambiguation() {
120+ m.keyMap.Models.SetHelp("ctrl+m", "models")
121+ m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
122+ }
91123 case tea.KeyPressMsg:
92− if m.dialog.HasDialogs() {
124+ if hasDialogs {
93125 m.updateDialogs(msg, &cmds)
94− } else {
126+ }
127+ }
128+
129+ if !hasDialogs {
130+ // This branch only handles UI elements when there's no dialog shown.
131+ switch msg := msg.(type) {
132+ case tea.KeyPressMsg:
95133 switch {
96134 case key.Matches(msg, m.keyMap.Tab):
97135 if m.state == uiChat {
98136 m.state = uiEdit
99− cmds = append(cmds, m.editor.Focus())
137+ cmds = append(cmds, m.textarea.Focus())
100138 } else {
101139 m.state = uiChat
102− cmds = append(cmds, m.editor.Blur())
140+ m.textarea.Blur()
103141 }
104142 case key.Matches(msg, m.keyMap.Help):
105143 m.help.ShowAll = !m.help.ShowAll
@@ -109,10 +147,31 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109147 m.dialog.AddDialog(dialog.NewQuit(m.com))
110148 return m, nil
111149 }
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
112156 default:
113157 m.updateFocused(msg, &cmds)
114158 }
115159 }
160+
161+ // This logic gets triggered on any message type, but should it?
162+ switch m.state {
163+ case uiChat:
164+ case uiEdit:
165+ // Textarea placeholder logic
166+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167+ m.textarea.Placeholder = m.workingPlaceholder
168+ } else {
169+ m.textarea.Placeholder = m.readyPlaceholder
170+ }
171+ if m.com.App.Permissions.SkipRequests() {
172+ m.textarea.Placeholder = "Yolo mode!"
173+ }
174+ }
116175 }
117176
118177 return m, tea.Batch(cmds...)
@@ -126,7 +185,7 @@ func (m *UI) View() tea.View {
126185 layers := []*lipgloss.Layer{}
127186
128187 // Determine the help key map based on focus
129− helpKeyMap := m.focusedKeyMap()
188+ var helpKeyMap help.KeyMap = m
130189
131190 // The screen areas we're working with
132191 area := m.layout.area
@@ -137,11 +196,6 @@ func (m *UI) View() tea.View {
137196
138197 if m.dialog.HasDialogs() {
139198 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
143− }
144−
145199 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
146200 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
147201 layers = append(layers,
@@ -153,8 +207,8 @@ func (m *UI) View() tea.View {
153207 }
154208 }
155209
156− if m.state == uiEdit && m.editor.Focused() {
157− cur := m.editor.Cursor()
210+ if m.state == uiEdit && m.textarea.Focused() {
211+ cur := m.textarea.Cursor()
158212 cur.X++ // Adjust for app margins
159213 cur.Y += editRect.Min.Y
160214 v.Cursor = cur
@@ -171,7 +225,7 @@ func (m *UI) View() tea.View {
171225 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
172226 lipgloss.NewLayer(m.side.View()).
173227 X(sideRect.Min.X).Y(sideRect.Min.Y),
174− lipgloss.NewLayer(m.editor.View()).
228+ lipgloss.NewLayer(m.textarea.View()).
175229 X(editRect.Min.X).Y(editRect.Min.Y),
176230 lipgloss.NewLayer(m.help.View(helpKeyMap)).
177231 X(helpRect.Min.X).Y(helpRect.Min.Y),
@@ -189,11 +243,82 @@ func (m *UI) View() tea.View {
189243 return v
190244}
191245
192−func (m *UI) focusedKeyMap() help.KeyMap {
193− if m.state == uiChat {
194− return m.chat
246+// ShortHelp implements [help.KeyMap].
247+func (m *UI) ShortHelp() []key.Binding {
248+ var binds []key.Binding
249+ k := &m.keyMap
250+
251+ if m.sess == nil {
252+ // no session selected
253+ binds = append(binds,
254+ k.Commands,
255+ k.Models,
256+ k.Editor.Newline,
257+ k.Quit,
258+ k.Help,
259+ )
260+ } else {
261+ // we have a session
195262 }
196− return m.editor
263+
264+ // switch m.state {
265+ // case uiChat:
266+ // case uiEdit:
267+ // binds = append(binds,
268+ // k.Editor.AddFile,
269+ // k.Editor.SendMessage,
270+ // k.Editor.OpenEditor,
271+ // k.Editor.Newline,
272+ // )
273+ //
274+ // if len(m.attachments) > 0 {
275+ // binds = append(binds,
276+ // k.Editor.AttachmentDeleteMode,
277+ // k.Editor.DeleteAllAttachments,
278+ // k.Editor.Escape,
279+ // )
280+ // }
281+ // }
282+
283+ return binds
284+}
285+
286+// FullHelp implements [help.KeyMap].
287+func (m *UI) FullHelp() [][]key.Binding {
288+ var binds [][]key.Binding
289+ k := &m.keyMap
290+ help := k.Help
291+ help.SetHelp("ctrl+g", "less")
292+
293+ if m.sess == nil {
294+ // no session selected
295+ binds = append(binds,
296+ []key.Binding{
297+ k.Commands,
298+ k.Models,
299+ k.Sessions,
300+ },
301+ []key.Binding{
302+ k.Editor.Newline,
303+ k.Editor.AddImage,
304+ k.Editor.MentionFile,
305+ k.Editor.OpenEditor,
306+ },
307+ []key.Binding{
308+ help,
309+ },
310+ )
311+ } else {
312+ // we have a session
313+ }
314+
315+ // switch m.state {
316+ // case uiChat:
317+ // case uiEdit:
318+ // binds = append(binds, m.ShortHelp())
319+ // }
320+
321+ return binds
197322}
198323
199324// 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) {
213338 case uiChat:
214339 m.updateChat(msg, cmds)
215340 case uiEdit:
216− m.updateEditor(msg, cmds)
341+ switch {
342+ case key.Matches(msg, m.keyMap.Editor.Newline):
343+ m.textarea.InsertRune('\n')
344+ }
345+
346+ ta, cmd := m.textarea.Update(msg)
347+ m.textarea = ta
348+ if cmd != nil {
349+ *cmds = append(*cmds, cmd)
350+ }
217351 }
218352}
219353
@@ -227,26 +361,13 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
227361 }
228362}
229363
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
235− if cmd != nil {
236− *cmds = append(*cmds, cmd)
237− }
238−}
239−
240364// updateLayoutAndSize updates the layout and sub-models sizes based on the
241365// given terminal width and height given in cells.
242366func (m *UI) updateLayoutAndSize(w, h int) {
243367 // The screen area we're working with
244368 area := image.Rect(0, 0, w, h)
245− helpKeyMap := m.focusedKeyMap()
369+ var helpKeyMap help.KeyMap = m
246370 helpHeight := 1
247− if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
248− helpKeyMap = m.dialog
249− }
250371 if m.help.ShowAll {
251372 for _, row := range helpKeyMap.FullHelp() {
252373 helpHeight = max(helpHeight, len(row))
@@ -280,8 +401,9 @@ func (m *UI) updateLayoutAndSize(w, h int) {
280401
281402 // Update sub-model sizes
282403 m.side.SetWidth(m.layout.sidebar.Dx())
283− m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
284− m.help.Width = m.layout.help.Dx()
404+ m.textarea.SetWidth(m.layout.editor.Dx())
405+ m.textarea.SetHeight(m.layout.editor.Dy())
406+ m.help.SetWidth(m.layout.help.Dx())
285407}
286408
287409// layout defines the positioning of UI elements.
@@ -304,3 +426,58 @@ type layout struct {
304426 // help is the area for the help view.
305427 help uv.Rectangle
306428}
429+
430+func (m *UI) setEditorPrompt() {
431+ if m.com.App.Permissions.SkipRequests() {
432+ m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
433+ return
434+ }
435+ m.textarea.SetPromptFunc(4, m.normalPromptFunc)
436+}
437+
438+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
439+ t := m.com.Styles
440+ if info.LineNumber == 0 {
441+ return " > "
442+ }
443+ if info.Focused {
444+ return t.EditorPromptNormalFocused.Render()
445+ }
446+ return t.EditorPromptNormalBlurred.Render()
447+}
448+
449+func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
450+ t := m.com.Styles
451+ if info.LineNumber == 0 {
452+ if info.Focused {
453+ return t.EditorPromptYoloIconFocused.Render()
454+ } else {
455+ return t.EditorPromptYoloIconBlurred.Render()
456+ }
457+ }
458+ if info.Focused {
459+ return t.EditorPromptYoloDotsFocused.Render()
460+ }
461+ return t.EditorPromptYoloDotsBlurred.Render()
462+}
463+
464+var readyPlaceholders = [...]string{
465+ "Ready!",
466+ "Ready...",
467+ "Ready?",
468+ "Ready for instructions",
469+}
470+
471+var workingPlaceholders = [...]string{
472+ "Working!",
473+ "Working...",
474+ "Brrrrr...",
475+ "Prrrrrrrr...",
476+ "Processing...",
477+ "Thinking...",
478+}
479+
480+func (m *UI) randomizePlaceholders() {
481+ m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482+ m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
483+}