@@ -1,8 +1,10 @@
11package model
22
33import (
4+ "fmt"
45 "image"
56 "math/rand"
7+ "os"
68 "slices"
79 "strings"
810
@@ -11,19 +13,36 @@ import (
1113 "charm.land/bubbles/v2/textarea"
1214 tea "charm.land/bubbletea/v2"
1315 "charm.land/lipgloss/v2"
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/home"
1418 "github.com/charmbracelet/crush/internal/session"
1519 "github.com/charmbracelet/crush/internal/ui/common"
1620 "github.com/charmbracelet/crush/internal/ui/dialog"
21+ "github.com/charmbracelet/crush/internal/ui/logo"
22+ "github.com/charmbracelet/crush/internal/ui/styles"
23+ "github.com/charmbracelet/crush/internal/version"
1724 uv "github.com/charmbracelet/ultraviolet"
1825)
1926
20−// uiState represents the current focus state of the UI.
27+// uiFocusState represents the current focus state of the UI.
28+type uiFocusState uint8
29+
30+// Possible uiFocusState values.
31+const (
32+ uiFocusNone uiFocusState = iota
33+ uiFocusEditor
34+ uiFocusMain
35+)
36+
2137type uiState uint8
2238
2339// Possible uiState values.
2440const (
25− uiEdit uiState = iota
41+ uiConfigure uiState = iota
42+ uiInitialize
43+ uiLanding
2644 uiChat
45+ uiChatCompact
2746)
2847
2948// UI represents the main user interface model.
@@ -31,6 +50,7 @@ type UI struct {
3150 com *common.Common
3251 sess *session.Session
3352
53+ focus uiFocusState
3454 state uiState
3555
3656 keyMap KeyMap
@@ -41,6 +61,9 @@ type UI struct {
4161 dialog *dialog.Overlay
4262 help help.Model
4363
64+ // header is the last cached header logo
65+ header string
66+
4467 layout layout
4568
4669 // sendProgressBar instructs the TUI to send progress bar updates to the
@@ -58,6 +81,9 @@ type UI struct {
5881
5982 readyPlaceholder string
6083 workingPlaceholder string
84+
85+ // Initialize state
86+ yesInitializeSelected bool
6187}
6288
6389// New creates a new instance of the [UI] model.
@@ -76,7 +102,23 @@ func New(com *common.Common) *UI {
76102 keyMap: DefaultKeyMap(),
77103 side: NewSidebarModel(com),
78104 help: help.New(),
105+ focus: uiFocusNone,
106+ state: uiConfigure,
79107 textarea: ta,
108+
109+ // initialize
110+ yesInitializeSelected: true,
111+ }
112+ // If no provider is configured show the user the provider list
113+ if !com.Config().IsConfigured() {
114+ ui.state = uiConfigure
115+ // if the project needs initialization show the user the question
116+ } else if n, _ := config.ProjectNeedsInitialization(); n {
117+ ui.state = uiInitialize
118+ // otherwise go to the landing UI
119+ } else {
120+ ui.state = uiLanding
121+ ui.focus = uiFocusEditor
80122 }
81123
82124 ui.setEditorPrompt()
@@ -91,7 +133,6 @@ func (m *UI) Init() tea.Cmd {
91133 if m.QueryVersion {
92134 return tea.RequestTerminalVersion
93135 }
94−
95136 return nil
96137}
97138
@@ -132,11 +173,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132173 case tea.KeyPressMsg:
133174 switch {
134175 case key.Matches(msg, m.keyMap.Tab):
135− if m.state == uiChat {
136− m.state = uiEdit
176+ if m.focus == uiFocusMain {
177+ m.focus = uiFocusEditor
137178 cmds = append(cmds, m.textarea.Focus())
138179 } else {
139− m.state = uiChat
180+ m.focus = uiFocusMain
140181 m.textarea.Blur()
141182 }
142183 case key.Matches(msg, m.keyMap.Help):
@@ -159,9 +200,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
159200 }
160201
161202 // This logic gets triggered on any message type, but should it?
162− switch m.state {
163− case uiChat:
164− case uiEdit:
203+ switch m.focus {
204+ case uiFocusMain:
205+ case uiFocusEditor:
165206 // Textarea placeholder logic
166207 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167208 m.textarea.Placeholder = m.workingPlaceholder
@@ -181,6 +222,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181222func (m *UI) View() tea.View {
182223 var v tea.View
183224 v.AltScreen = true
225+ v.BackgroundColor = m.com.Styles.Background
184226
185227 layers := []*lipgloss.Layer{}
186228
@@ -189,7 +231,8 @@ func (m *UI) View() tea.View {
189231
190232 // The screen areas we're working with
191233 area := m.layout.area
192− chatRect := m.layout.chat
234+ headerRect := m.layout.header
235+ mainRect := m.layout.main
193236 sideRect := m.layout.sidebar
194237 editRect := m.layout.editor
195238 helpRect := m.layout.help
@@ -207,7 +250,7 @@ func (m *UI) View() tea.View {
207250 }
208251 }
209252
210− if m.state == uiEdit && m.textarea.Focused() {
253+ if m.focus == uiFocusEditor && m.textarea.Focused() {
211254 cur := m.textarea.Cursor()
212255 cur.X++ // Adjust for app margins
213256 cur.Y += editRect.Min.Y
@@ -215,24 +258,70 @@ func (m *UI) View() tea.View {
215258 }
216259
217260 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
218− Width(area.Dx()).Height(area.Dy()).
219− AddLayers(
220− lipgloss.NewLayer(
221− lipgloss.NewStyle().Width(chatRect.Dx()).
222− Height(chatRect.Dy()).
223− Background(lipgloss.ANSIColor(rand.Intn(256))).
224− Render(" Main View "),
225− ).X(chatRect.Min.X).Y(chatRect.Min.Y),
226− lipgloss.NewLayer(m.side.View()).
227− X(sideRect.Min.X).Y(sideRect.Min.Y),
228− lipgloss.NewLayer(m.textarea.View()).
229− X(editRect.Min.X).Y(editRect.Min.Y),
230− lipgloss.NewLayer(m.help.View(helpKeyMap)).
231− X(helpRect.Min.X).Y(helpRect.Min.Y),
232− )
261+ Width(area.Dx()).Height(area.Dy())
262+
263+ switch m.state {
264+ case uiConfigure:
265+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
266+ main := lipgloss.NewLayer(
267+ lipgloss.NewStyle().Width(mainRect.Dx()).
268+ Height(mainRect.Dy()).
269+ Background(lipgloss.ANSIColor(rand.Intn(256))).
270+ Render(" Configure "),
271+ ).X(mainRect.Min.X).Y(mainRect.Min.Y)
272+ mainLayer = mainLayer.AddLayers(header, main)
273+ case uiInitialize:
274+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
275+ main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y)
276+ mainLayer = mainLayer.AddLayers(header, main)
277+ case uiLanding:
278+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
279+ main := lipgloss.NewLayer(
280+ lipgloss.NewStyle().Width(mainRect.Dx()).
281+ Height(mainRect.Dy()).
282+ Background(lipgloss.ANSIColor(rand.Intn(256))).
283+ Render(" Landing Page "),
284+ ).X(mainRect.Min.X).Y(mainRect.Min.Y)
285+ editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
286+ mainLayer = mainLayer.AddLayers(header, main, editor)
287+ case uiChat:
288+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
289+ side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y)
290+ main := lipgloss.NewLayer(
291+ lipgloss.NewStyle().Width(mainRect.Dx()).
292+ Height(mainRect.Dy()).
293+ Background(lipgloss.ANSIColor(rand.Intn(256))).
294+ Render(" Chat Messages "),
295+ ).X(mainRect.Min.X).Y(mainRect.Min.Y)
296+ editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
297+ mainLayer = mainLayer.AddLayers(header, main, side, editor)
298+ case uiChatCompact:
299+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
300+ main := lipgloss.NewLayer(
301+ lipgloss.NewStyle().Width(mainRect.Dx()).
302+ Height(mainRect.Dy()).
303+ Background(lipgloss.ANSIColor(rand.Intn(256))).
304+ Render(" Compact Chat Messages "),
305+ ).X(mainRect.Min.X).Y(mainRect.Min.Y)
306+ editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
307+ mainLayer = mainLayer.AddLayers(header, main, editor)
308+ }
309+
310+ // Add help layer
311+ help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y)
312+ mainLayer = mainLayer.AddLayers(help)
233313
234314 layers = append(layers, mainLayer)
235315
316+ // Debugging rendering (visually see when the tui rerenders)
317+ if os.Getenv("CRUSH_UI_DEBUG") == "true" {
318+ content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
319+ debugLayer := lipgloss.NewLayer(content).
320+ X(4).
321+ Y(1)
322+ layers = append(layers, debugLayer)
323+ }
324+
236325 v.Content = lipgloss.NewCanvas(layers...)
237326 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
238327 // HACK: use a random percentage to prevent ghostty from hiding it
@@ -248,37 +337,44 @@ func (m *UI) ShortHelp() []key.Binding {
248337 var binds []key.Binding
249338 k := &m.keyMap
250339
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
262− }
340+ switch m.state {
341+ case uiInitialize:
342+ binds = append(binds, k.Quit)
343+ default:
344+ // TODO: other states
345+ if m.sess == nil {
346+ // no session selected
347+ binds = append(binds,
348+ k.Commands,
349+ k.Models,
350+ k.Editor.Newline,
351+ k.Quit,
352+ k.Help,
353+ )
354+ } else {
355+ // we have a session
356+ }
263357
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− // }
358+ // switch m.state {
359+ // case uiChat:
360+ // case uiEdit:
361+ // binds = append(binds,
362+ // k.Editor.AddFile,
363+ // k.Editor.SendMessage,
364+ // k.Editor.OpenEditor,
365+ // k.Editor.Newline,
366+ // )
367+ //
368+ // if len(m.attachments) > 0 {
369+ // binds = append(binds,
370+ // k.Editor.AttachmentDeleteMode,
371+ // k.Editor.DeleteAllAttachments,
372+ // k.Editor.Escape,
373+ // )
374+ // }
375+ // }
376+
377+ }
282378
283379 return binds
284380}
@@ -290,26 +386,34 @@ func (m *UI) FullHelp() [][]key.Binding {
290386 help := k.Help
291387 help.SetHelp("ctrl+g", "less")
292388
293− if m.sess == nil {
294− // no session selected
389+ switch m.state {
390+ case uiInitialize:
295391 binds = append(binds,
296392 []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
393+ k.Quit,
394+ })
395+ default:
396+ if m.sess == nil {
397+ // no session selected
398+ binds = append(binds,
399+ []key.Binding{
400+ k.Commands,
401+ k.Models,
402+ k.Sessions,
403+ },
404+ []key.Binding{
405+ k.Editor.Newline,
406+ k.Editor.AddImage,
407+ k.Editor.MentionFile,
408+ k.Editor.OpenEditor,
409+ },
410+ []key.Binding{
411+ help,
412+ },
413+ )
414+ } else {
415+ // we have a session
416+ }
313417 }
314418
315419 // switch m.state {
@@ -334,10 +438,10 @@ func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
334438// updateFocused updates the focused model (chat or editor) with the given message
335439// and appends any resulting commands to the cmds slice.
336440func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
337− switch m.state {
338− case uiChat:
441+ switch m.focus {
442+ case uiFocusMain:
339443 m.updateChat(msg, cmds)
340− case uiEdit:
444+ case uiFocusEditor:
341445 switch {
342446 case key.Matches(msg, m.keyMap.Editor.Newline):
343447 m.textarea.InsertRune('\n')
@@ -366,8 +470,18 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
366470func (m *UI) updateLayoutAndSize(w, h int) {
367471 // The screen area we're working with
368472 area := image.Rect(0, 0, w, h)
369− var helpKeyMap help.KeyMap = m
473+
474+ // The help height
370475 helpHeight := 1
476+ // The editor height
477+ editorHeight := 5
478+ // The sidebar width
479+ sidebarWidth := 40
480+ // The header height
481+ // TODO: handle compact
482+ headerHeight := 4
483+
484+ var helpKeyMap help.KeyMap = m
371485 if m.help.ShowAll {
372486 for _, row := range helpKeyMap.FullHelp() {
373487 helpHeight = max(helpHeight, len(row))
@@ -375,35 +489,103 @@ func (m *UI) updateLayoutAndSize(w, h int) {
375489 }
376490
377491 // Add app margins
378− mainRect := area
379− mainRect.Min.X += 1
380− mainRect.Min.Y += 1
381− mainRect.Max.X -= 1
382− mainRect.Max.Y -= 1
383−
384− mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
385− chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
386− chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
492+ appRect := area
493+ appRect.Min.X += 1
494+ appRect.Min.Y += 1
495+ appRect.Max.X -= 1
496+ appRect.Max.Y -= 1
497+
498+ if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
499+ // extra padding on left and right for these states
500+ appRect.Min.X += 1
501+ appRect.Max.X -= 1
502+ }
387503
388− // Add 1 line margin bottom of chatRect
389− chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
390− // Add 1 line margin bottom of editRect
391− editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
504+ appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
392505
393506 m.layout = layout{
394− area: area,
395− main: mainRect,
396− chat: chatRect,
397− editor: editRect,
398− sidebar: sideRect,
399− help: helpRect,
507+ area: area,
508+ help: helpRect,
400509 }
401510
402− // Update sub-model sizes
403− m.side.SetWidth(m.layout.sidebar.Dx())
404− m.textarea.SetWidth(m.layout.editor.Dx())
405− m.textarea.SetHeight(m.layout.editor.Dy())
511+ // Set help width
406512 m.help.SetWidth(m.layout.help.Dx())
513+
514+ // Handle different app states
515+ switch m.state {
516+ case uiConfigure, uiInitialize:
517+ // Layout
518+ //
519+ // header
520+ // ------
521+ // main
522+ // ------
523+ // help
524+
525+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
526+ m.layout.header = headerRect
527+ m.layout.main = mainRect
528+ m.renderHeader(false, m.layout.header.Dx())
529+
530+ case uiLanding:
531+ // Layout
532+ //
533+ // header
534+ // ------
535+ // main
536+ // ------
537+ // editor
538+ // ------
539+ // help
540+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
541+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
542+ m.layout.header = headerRect
543+ m.layout.main = mainRect
544+ m.layout.editor = editorRect
545+ // TODO: set the width and heigh of the chat component
546+ m.renderHeader(false, m.layout.header.Dx())
547+ m.textarea.SetWidth(m.layout.editor.Dx())
548+ m.textarea.SetHeight(m.layout.editor.Dy())
549+
550+ case uiChat:
551+ // Layout
552+ //
553+ // ------|---
554+ // main |
555+ // ------| side
556+ // editor|
557+ // ----------
558+ // help
559+
560+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
561+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
562+ m.layout.sidebar = sideRect
563+ m.layout.main = mainRect
564+ m.layout.editor = editorRect
565+ // TODO: set the width and heigh of the chat component
566+ m.side.SetWidth(m.layout.sidebar.Dx())
567+ m.textarea.SetWidth(m.layout.editor.Dx())
568+ m.textarea.SetHeight(m.layout.editor.Dy())
569+ case uiChatCompact:
570+ // Layout
571+ //
572+ // compact-header
573+ // ------
574+ // main
575+ // ------
576+ // editor
577+ // ------
578+ // help
579+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
580+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
581+ m.layout.header = headerRect
582+ m.layout.main = mainRect
583+ m.layout.editor = editorRect
584+ // TODO: set the width and heigh of the chat component
585+ m.renderHeader(true, m.layout.header.Dx())
586+ m.textarea.SetWidth(m.layout.editor.Dx())
587+ m.textarea.SetHeight(m.layout.editor.Dy())
588+ }
407589}
408590
409591// layout defines the positioning of UI elements.
@@ -411,11 +593,14 @@ type layout struct {
411593 // area is the overall available area.
412594 area uv.Rectangle
413595
414− // main is the main area excluding help.
415− main uv.Rectangle
596+ // header is the header shown in special cases
597+ // e.x when the sidebar is collapsed
598+ // or when in the landing page
599+ // or in init/config
600+ header uv.Rectangle
416601
417− // chat is the area for the chat pane.
418− chat uv.Rectangle
602+ // main is the area for the main pane. (e.x chat, configure, landing)
603+ main uv.Rectangle
419604
420605 // editor is the area for the editor pane.
421606 editor uv.Rectangle
@@ -481,3 +666,57 @@ func (m *UI) randomizePlaceholders() {
481666 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482667 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
483668}
669+
670+func (m *UI) initializeView() string {
671+ cfg := m.com.Config()
672+ s := m.com.Styles.Initialize
673+ cwd := home.Short(cfg.WorkingDir())
674+ initFile := cfg.Options.InitializeAs
675+
676+ header := s.Header.Render("Would you like to initialize this project?")
677+ path := s.Accent.PaddingLeft(2).Render(cwd)
678+ desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile))
679+ hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
680+ prompt := s.Content.Render("Would you like to initialize now?")
681+
682+ buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
683+ {Text: "Yep!", Selected: m.yesInitializeSelected},
684+ {Text: "Nope", Selected: !m.yesInitializeSelected},
685+ }, " ")
686+
687+ // max width 60 so the text is compact
688+ width := min(m.layout.main.Dx(), 60)
689+
690+ return lipgloss.NewStyle().
691+ Width(width).
692+ Height(m.layout.main.Dy()).
693+ PaddingBottom(1).
694+ AlignVertical(lipgloss.Bottom).
695+ Render(strings.Join(
696+ []string{
697+ header,
698+ path,
699+ desc,
700+ hint,
701+ prompt,
702+ buttons,
703+ },
704+ "\n\n",
705+ ))
706+}
707+
708+func (m *UI) renderHeader(compact bool, width int) {
709+ // TODO: handle the compact case differently
710+ m.header = renderLogo(m.com.Styles, compact, width)
711+}
712+
713+func renderLogo(t *styles.Styles, compact bool, width int) string {
714+ return logo.Render(version.Version, compact, logo.Opts{
715+ FieldColor: t.LogoFieldColor,
716+ TitleColorA: t.LogoTitleColorA,
717+ TitleColorB: t.LogoTitleColorB,
718+ CharmColor: t.LogoCharmColor,
719+ VersionColor: t.LogoVersionColor,
720+ Width: max(0, width-2),
721+ })
722+}