on main help sign in
refactor(ui): rework init and support different layouts (#1463)
efaa956Kujtim Hoxha
internal/ui/common/button.gonew file+69
@@ -0,0 +1,69 @@
1+package common
2+
3+import (
4+ "strings"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+)
9+
10+// ButtonOpts defines the configuration for a single button
11+type ButtonOpts struct {
12+ // Text is the button label
13+ Text string
14+ // UnderlineIndex is the 0-based index of the character to underline (-1 for none)
15+ UnderlineIndex int
16+ // Selected indicates whether this button is currently selected
17+ Selected bool
18+ // Padding inner horizontal padding defaults to 2 if this is 0
19+ Padding int
20+}
21+
22+// Button creates a button with an underlined character and selection state
23+func Button(t *styles.Styles, opts ButtonOpts) string {
24+ // Select style based on selection state
25+ style := t.ButtonBlur
26+ if opts.Selected {
27+ style = t.ButtonFocus
28+ }
29+
30+ text := opts.Text
31+ if opts.Padding == 0 {
32+ opts.Padding = 2
33+ }
34+
35+ // the index is out of bound
36+ if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 {
37+ opts.UnderlineIndex = -1
38+ }
39+
40+ text = style.Padding(0, opts.Padding).Render(text)
41+
42+ if opts.UnderlineIndex != -1 {
43+ text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true)))
44+ }
45+
46+ return text
47+}
48+
49+// ButtonGroup creates a row of selectable buttons
50+// Spacing is the separator between buttons
51+// Use " " or similar for horizontal layout
52+// Use "\n" for vertical layout
53+// Defaults to " " (horizontal)
54+func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string {
55+ if len(buttons) == 0 {
56+ return ""
57+ }
58+
59+ if spacing == "" {
60+ spacing = " "
61+ }
62+
63+ parts := make([]string, len(buttons))
64+ for i, button := range buttons {
65+ parts[i] = Button(t, button)
66+ }
67+
68+ return strings.Join(parts, spacing)
69+}
internal/ui/dialog/quit.go+7−19
@@ -60,8 +60,9 @@ type Quit struct {
60// NewQuit creates a new quit confirmation dialog.
60// NewQuit creates a new quit confirmation dialog.
61func NewQuit(com *common.Common) *Quit {
61func NewQuit(com *common.Common) *Quit {
62 q := &Quit{
62 q := &Quit{
63− com: com,
63+ com: com,
64− keyMap: DefaultQuitKeyMap(),
64+ keyMap: DefaultQuitKeyMap(),
65+ selectedNo: true,
65 }
66 }
66 return q
67 return q
67}
68}
@@ -98,24 +99,11 @@ func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) {
98func (q *Quit) View() string {
99func (q *Quit) View() string {
99 const question = "Are you sure you want to quit?"
100 const question = "Are you sure you want to quit?"
100 baseStyle := q.com.Styles.Base
101 baseStyle := q.com.Styles.Base
101− yesStyle := q.com.Styles.ButtonSelected
102+ buttonOpts := []common.ButtonOpts{
102− noStyle := q.com.Styles.ButtonUnselected
103−
104− if q.selectedNo {
103+ {Text: "Yep!", Selected: !q.selectedNo, Padding: 3},
105− noStyle = q.com.Styles.ButtonSelected
104+ {Text: "Nope", Selected: q.selectedNo, Padding: 3},
106− yesStyle = q.com.Styles.ButtonUnselected
107 }
105 }
108−
106+ buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ")
109− const horizontalPadding = 3
110− yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
111− yesStyle.PaddingRight(horizontalPadding).Render("ep!")
112− noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
113− noStyle.PaddingRight(horizontalPadding).Render("ope")
114−
115− buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
116− lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
117− )
118−
119 content := baseStyle.Render(
107 content := baseStyle.Render(
120 lipgloss.JoinVertical(
108 lipgloss.JoinVertical(
121 lipgloss.Center,
109 lipgloss.Center,
internal/ui/model/keys.go+19
@@ -17,6 +17,12 @@ type KeyMap struct {
17 DeleteAllAttachments key.Binding
17 DeleteAllAttachments key.Binding
18 }
18 }
19
19
20+ Initialize struct {
21+ Yes,
22+ No,
23+ Switch key.Binding
24+ }
25+
20 // Global key maps
26 // Global key maps
21 Quit key.Binding
27 Quit key.Binding
22 Help key.Binding
28 Help key.Binding
@@ -99,5 +105,18 @@ func DefaultKeyMap() KeyMap {
99 key.WithHelp("ctrl+r+r", "delete all attachments"),
105 key.WithHelp("ctrl+r+r", "delete all attachments"),
100 )
106 )
101
107
108+ km.Initialize.Yes = key.NewBinding(
109+ key.WithKeys("y", "Y"),
110+ key.WithHelp("y", "yes"),
111+ )
112+ km.Initialize.No = key.NewBinding(
113+ key.WithKeys("n", "N", "esc", "alt+esc"),
114+ key.WithHelp("n", "no"),
115+ )
116+ km.Initialize.Switch = key.NewBinding(
117+ key.WithKeys("left", "right", "tab"),
118+ key.WithHelp("tab", "switch"),
119+ )
120+
102 return km
121 return km
103}
122}
internal/ui/model/sidebar.go+1−15
@@ -4,9 +4,6 @@ import (
4 tea "charm.land/bubbletea/v2"
4 tea "charm.land/bubbletea/v2"
5 "charm.land/lipgloss/v2"
5 "charm.land/lipgloss/v2"
6 "github.com/charmbracelet/crush/internal/ui/common"
6 "github.com/charmbracelet/crush/internal/ui/common"
7− "github.com/charmbracelet/crush/internal/ui/logo"
8− "github.com/charmbracelet/crush/internal/ui/styles"
9− "github.com/charmbracelet/crush/internal/version"
10)
7)
11
8
12// SidebarModel is the model for the sidebar UI component.
9// SidebarModel is the model for the sidebar UI component.
@@ -65,17 +62,6 @@ func (m *SidebarModel) View() string {
65
62
66// SetWidth sets the width of the sidebar and updates the logo accordingly.
63// SetWidth sets the width of the sidebar and updates the logo accordingly.
67func (m *SidebarModel) SetWidth(width int) {
64func (m *SidebarModel) SetWidth(width int) {
68− m.logo = logoBlock(m.com.Styles, width)
65+ m.logo = renderLogo(m.com.Styles, true, width)
69 m.width = width
66 m.width = width
70}
67}
71−
72−func logoBlock(t *styles.Styles, width int) string {
73− return logo.Render(version.Version, true, logo.Opts{
74− FieldColor: t.LogoFieldColor,
75− TitleColorA: t.LogoTitleColorA,
76− TitleColorB: t.LogoTitleColorB,
77− CharmColor: t.LogoCharmColor,
78− VersionColor: t.LogoVersionColor,
79− Width: max(0, width-2),
80− })
81−}
internal/ui/model/ui.go+344−105
@@ -1,8 +1,10 @@
1package model
1package model
2
2
3import (
3import (
4+ "fmt"
4 "image"
5 "image"
5 "math/rand"
6 "math/rand"
7+ "os"
6 "slices"
8 "slices"
7 "strings"
9 "strings"
8
10
@@ -11,19 +13,36 @@ import (
11 "charm.land/bubbles/v2/textarea"
13 "charm.land/bubbles/v2/textarea"
12 tea "charm.land/bubbletea/v2"
14 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
15 "charm.land/lipgloss/v2"
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/home"
14 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/ui/common"
19 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/dialog"
20 "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"
17 uv "github.com/charmbracelet/ultraviolet"
24 uv "github.com/charmbracelet/ultraviolet"
18)
25)
19
26
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+
21type uiState uint8
37type uiState uint8
22
38
23// Possible uiState values.
39// Possible uiState values.
24const (
40const (
25− uiEdit uiState = iota
41+ uiConfigure uiState = iota
42+ uiInitialize
43+ uiLanding
26 uiChat
44 uiChat
45+ uiChatCompact
27)
46)
28
47
29// UI represents the main user interface model.
48// UI represents the main user interface model.
@@ -31,6 +50,7 @@ type UI struct {
31 com *common.Common
50 com *common.Common
32 sess *session.Session
51 sess *session.Session
33
52
53+ focus uiFocusState
34 state uiState
54 state uiState
35
55
36 keyMap KeyMap
56 keyMap KeyMap
@@ -41,6 +61,9 @@ type UI struct {
41 dialog *dialog.Overlay
61 dialog *dialog.Overlay
42 help help.Model
62 help help.Model
43
63
64+ // header is the last cached header logo
65+ header string
66+
44 layout layout
67 layout layout
45
68
46 // sendProgressBar instructs the TUI to send progress bar updates to the
69 // sendProgressBar instructs the TUI to send progress bar updates to the
@@ -58,6 +81,9 @@ type UI struct {
58
81
59 readyPlaceholder string
82 readyPlaceholder string
60 workingPlaceholder string
83 workingPlaceholder string
84+
85+ // Initialize state
86+ yesInitializeSelected bool
61}
87}
62
88
63// New creates a new instance of the [UI] model.
89// New creates a new instance of the [UI] model.
@@ -76,7 +102,23 @@ func New(com *common.Common) *UI {
76 keyMap: DefaultKeyMap(),
102 keyMap: DefaultKeyMap(),
77 side: NewSidebarModel(com),
103 side: NewSidebarModel(com),
78 help: help.New(),
104 help: help.New(),
105+ focus: uiFocusNone,
106+ state: uiConfigure,
79 textarea: ta,
107 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
80 }
122 }
81
123
82 ui.setEditorPrompt()
124 ui.setEditorPrompt()
@@ -91,7 +133,6 @@ func (m *UI) Init() tea.Cmd {
91 if m.QueryVersion {
133 if m.QueryVersion {
92 return tea.RequestTerminalVersion
134 return tea.RequestTerminalVersion
93 }
135 }
94−
95 return nil
136 return nil
96}
137}
97
138
@@ -132,11 +173,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132 case tea.KeyPressMsg:
173 case tea.KeyPressMsg:
133 switch {
174 switch {
134 case key.Matches(msg, m.keyMap.Tab):
175 case key.Matches(msg, m.keyMap.Tab):
135− if m.state == uiChat {
176+ if m.focus == uiFocusMain {
136− m.state = uiEdit
177+ m.focus = uiFocusEditor
137 cmds = append(cmds, m.textarea.Focus())
178 cmds = append(cmds, m.textarea.Focus())
138 } else {
179 } else {
139− m.state = uiChat
180+ m.focus = uiFocusMain
140 m.textarea.Blur()
181 m.textarea.Blur()
141 }
182 }
142 case key.Matches(msg, m.keyMap.Help):
183 case key.Matches(msg, m.keyMap.Help):
@@ -159,9 +200,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
159 }
200 }
160
201
161 // This logic gets triggered on any message type, but should it?
202 // This logic gets triggered on any message type, but should it?
162− switch m.state {
203+ switch m.focus {
163− case uiChat:
204+ case uiFocusMain:
164− case uiEdit:
205+ case uiFocusEditor:
165 // Textarea placeholder logic
206 // Textarea placeholder logic
166 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
207 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
167 m.textarea.Placeholder = m.workingPlaceholder
208 m.textarea.Placeholder = m.workingPlaceholder
@@ -181,6 +222,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181func (m *UI) View() tea.View {
222func (m *UI) View() tea.View {
182 var v tea.View
223 var v tea.View
183 v.AltScreen = true
224 v.AltScreen = true
225+ v.BackgroundColor = m.com.Styles.Background
184
226
185 layers := []*lipgloss.Layer{}
227 layers := []*lipgloss.Layer{}
186
228
@@ -189,7 +231,8 @@ func (m *UI) View() tea.View {
189
231
190 // The screen areas we're working with
232 // The screen areas we're working with
191 area := m.layout.area
233 area := m.layout.area
192− chatRect := m.layout.chat
234+ headerRect := m.layout.header
235+ mainRect := m.layout.main
193 sideRect := m.layout.sidebar
236 sideRect := m.layout.sidebar
194 editRect := m.layout.editor
237 editRect := m.layout.editor
195 helpRect := m.layout.help
238 helpRect := m.layout.help
@@ -207,7 +250,7 @@ func (m *UI) View() tea.View {
207 }
250 }
208 }
251 }
209
252
210− if m.state == uiEdit && m.textarea.Focused() {
253+ if m.focus == uiFocusEditor && m.textarea.Focused() {
211 cur := m.textarea.Cursor()
254 cur := m.textarea.Cursor()
212 cur.X++ // Adjust for app margins
255 cur.X++ // Adjust for app margins
213 cur.Y += editRect.Min.Y
256 cur.Y += editRect.Min.Y
@@ -215,24 +258,70 @@ func (m *UI) View() tea.View {
215 }
258 }
216
259
217 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
260 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
218− Width(area.Dx()).Height(area.Dy()).
261+ Width(area.Dx()).Height(area.Dy())
219− AddLayers(
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)
220− lipgloss.NewLayer(
290+ main := lipgloss.NewLayer(
221− lipgloss.NewStyle().Width(chatRect.Dx()).
291+ lipgloss.NewStyle().Width(mainRect.Dx()).
222− Height(chatRect.Dy()).
292+ Height(mainRect.Dy()).
223− Background(lipgloss.ANSIColor(rand.Intn(256))).
293+ Background(lipgloss.ANSIColor(rand.Intn(256))).
224− Render(" Main View "),
294+ Render(" Chat Messages "),
225− ).X(chatRect.Min.X).Y(chatRect.Min.Y),
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:
226− lipgloss.NewLayer(m.side.View()).
299+ header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y)
227− X(sideRect.Min.X).Y(sideRect.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)
228− lipgloss.NewLayer(m.textarea.View()).
306+ editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y)
229− X(editRect.Min.X).Y(editRect.Min.Y),
307+ mainLayer = mainLayer.AddLayers(header, main, editor)
308+ }
309+
310+ // Add help layer
230− lipgloss.NewLayer(m.help.View(helpKeyMap)).
311+ help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y)
231− X(helpRect.Min.X).Y(helpRect.Min.Y),
312+ mainLayer = mainLayer.AddLayers(help)
232− )
233
313
234 layers = append(layers, mainLayer)
314 layers = append(layers, mainLayer)
235
315
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+
236 v.Content = lipgloss.NewCanvas(layers...)
325 v.Content = lipgloss.NewCanvas(layers...)
237 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
326 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
238 // HACK: use a random percentage to prevent ghostty from hiding it
327 // HACK: use a random percentage to prevent ghostty from hiding it
@@ -248,37 +337,44 @@ func (m *UI) ShortHelp() []key.Binding {
248 var binds []key.Binding
337 var binds []key.Binding
249 k := &m.keyMap
338 k := &m.keyMap
250
339
340+ switch m.state {
341+ case uiInitialize:
342+ binds = append(binds, k.Quit)
343+ default:
344+ // TODO: other states
251− if m.sess == nil {
345+ if m.sess == nil {
252− // no session selected
346+ // no session selected
253− binds = append(binds,
347+ binds = append(binds,
254− k.Commands,
348+ k.Commands,
255− k.Models,
349+ k.Models,
256− k.Editor.Newline,
350+ k.Editor.Newline,
257− k.Quit,
351+ k.Quit,
258− k.Help,
352+ k.Help,
259− )
353+ )
260− } else {
354+ } else {
261− // we have a session
355+ // we have a session
262− }
356+ }
263
357
264− // switch m.state {
358+ // switch m.state {
265− // case uiChat:
359+ // case uiChat:
266− // case uiEdit:
360+ // case uiEdit:
267− // binds = append(binds,
361+ // binds = append(binds,
268− // k.Editor.AddFile,
362+ // k.Editor.AddFile,
269− // k.Editor.SendMessage,
363+ // k.Editor.SendMessage,
270− // k.Editor.OpenEditor,
364+ // k.Editor.OpenEditor,
271− // k.Editor.Newline,
365+ // k.Editor.Newline,
272− // )
366+ // )
273− //
367+ //
274− // if len(m.attachments) > 0 {
368+ // if len(m.attachments) > 0 {
275− // binds = append(binds,
369+ // binds = append(binds,
276− // k.Editor.AttachmentDeleteMode,
370+ // k.Editor.AttachmentDeleteMode,
277− // k.Editor.DeleteAllAttachments,
371+ // k.Editor.DeleteAllAttachments,
278− // k.Editor.Escape,
372+ // k.Editor.Escape,
279− // )
373+ // )
280− // }
374+ // }
281− // }
375+ // }
376+
377+ }
282
378
283 return binds
379 return binds
284}
380}
@@ -290,26 +386,34 @@ func (m *UI) FullHelp() [][]key.Binding {
290 help := k.Help
386 help := k.Help
291 help.SetHelp("ctrl+g", "less")
387 help.SetHelp("ctrl+g", "less")
292
388
293− if m.sess == nil {
389+ switch m.state {
294− // no session selected
390+ case uiInitialize:
295 binds = append(binds,
391 binds = append(binds,
296 []key.Binding{
392 []key.Binding{
393+ k.Quit,
394+ })
395+ default:
396+ if m.sess == nil {
397+ // no session selected
398+ binds = append(binds,
399+ []key.Binding{
297− k.Commands,
400+ k.Commands,
298− k.Models,
401+ k.Models,
299− k.Sessions,
402+ k.Sessions,
300− },
403+ },
301− []key.Binding{
404+ []key.Binding{
302− k.Editor.Newline,
405+ k.Editor.Newline,
303− k.Editor.AddImage,
406+ k.Editor.AddImage,
304− k.Editor.MentionFile,
407+ k.Editor.MentionFile,
305− k.Editor.OpenEditor,
408+ k.Editor.OpenEditor,
306− },
409+ },
307− []key.Binding{
410+ []key.Binding{
308− help,
411+ help,
309− },
412+ },
310− )
413+ )
311− } else {
414+ } else {
312− // we have a session
415+ // we have a session
416+ }
313 }
417 }
314
418
315 // switch m.state {
419 // switch m.state {
@@ -334,10 +438,10 @@ func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
334// updateFocused updates the focused model (chat or editor) with the given message
438// updateFocused updates the focused model (chat or editor) with the given message
335// and appends any resulting commands to the cmds slice.
439// and appends any resulting commands to the cmds slice.
336func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
440func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
337− switch m.state {
441+ switch m.focus {
338− case uiChat:
442+ case uiFocusMain:
339 m.updateChat(msg, cmds)
443 m.updateChat(msg, cmds)
340− case uiEdit:
444+ case uiFocusEditor:
341 switch {
445 switch {
342 case key.Matches(msg, m.keyMap.Editor.Newline):
446 case key.Matches(msg, m.keyMap.Editor.Newline):
343 m.textarea.InsertRune('\n')
447 m.textarea.InsertRune('\n')
@@ -366,8 +470,18 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
366func (m *UI) updateLayoutAndSize(w, h int) {
470func (m *UI) updateLayoutAndSize(w, h int) {
367 // The screen area we're working with
471 // The screen area we're working with
368 area := image.Rect(0, 0, w, h)
472 area := image.Rect(0, 0, w, h)
369− var helpKeyMap help.KeyMap = m
473+
474+ // The help height
370 helpHeight := 1
475 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
371 if m.help.ShowAll {
485 if m.help.ShowAll {
372 for _, row := range helpKeyMap.FullHelp() {
486 for _, row := range helpKeyMap.FullHelp() {
373 helpHeight = max(helpHeight, len(row))
487 helpHeight = max(helpHeight, len(row))
@@ -375,35 +489,103 @@ func (m *UI) updateLayoutAndSize(w, h int) {
375 }
489 }
376
490
377 // Add app margins
491 // Add app margins
378− mainRect := area
492+ appRect := area
379− mainRect.Min.X += 1
493+ appRect.Min.X += 1
380− mainRect.Min.Y += 1
494+ appRect.Min.Y += 1
381− mainRect.Max.X -= 1
495+ appRect.Max.X -= 1
382− mainRect.Max.Y -= 1
496+ appRect.Max.Y -= 1
383−
497+
384− mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
498+ if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
385− chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
499+ // extra padding on left and right for these states
386− chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
500+ appRect.Min.X += 1
501+ appRect.Max.X -= 1
502+ }
387
503
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))
392
505
393 m.layout = layout{
506 m.layout = layout{
394− area: area,
507+ area: area,
395− main: mainRect,
396− chat: chatRect,
397− editor: editRect,
398− sidebar: sideRect,
399− help: helpRect,
508+ help: helpRect,
400 }
509 }
401
510
402− // Update sub-model sizes
511+ // Set help width
403− m.side.SetWidth(m.layout.sidebar.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())
512 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+ }
407}
589}
408
590
409// layout defines the positioning of UI elements.
591// layout defines the positioning of UI elements.
@@ -411,11 +593,14 @@ type layout struct {
411 // area is the overall available area.
593 // area is the overall available area.
412 area uv.Rectangle
594 area uv.Rectangle
413
595
414− // main is the main area excluding help.
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
415− main uv.Rectangle
600+ header uv.Rectangle
416
601
417− // chat is the area for the chat pane.
602+ // main is the area for the main pane. (e.x chat, configure, landing)
418− chat uv.Rectangle
603+ main uv.Rectangle
419
604
420 // editor is the area for the editor pane.
605 // editor is the area for the editor pane.
421 editor uv.Rectangle
606 editor uv.Rectangle
@@ -481,3 +666,57 @@ func (m *UI) randomizePlaceholders() {
481 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
666 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
482 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
667 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
483}
668}
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+}
internal/ui/styles/styles.go+19−4
@@ -98,8 +98,8 @@ type Styles struct {
98 FilePicker filepicker.Styles
98 FilePicker filepicker.Styles
99
99
100 // Buttons
100 // Buttons
101− ButtonSelected lipgloss.Style
101+ ButtonFocus lipgloss.Style
102− ButtonUnselected lipgloss.Style
102+ ButtonBlur lipgloss.Style
103
103
104 // Borders
104 // Borders
105 BorderFocus lipgloss.Style
105 BorderFocus lipgloss.Style
@@ -113,6 +113,8 @@ type Styles struct {
113 EditorPromptYoloDotsFocused lipgloss.Style
113 EditorPromptYoloDotsFocused lipgloss.Style
114 EditorPromptYoloDotsBlurred lipgloss.Style
114 EditorPromptYoloDotsBlurred lipgloss.Style
115
115
116+ // Background
117+ Background color.Color
116 // Logo
118 // Logo
117 LogoFieldColor color.Color
119 LogoFieldColor color.Color
118 LogoTitleColorA color.Color
120 LogoTitleColorA color.Color
@@ -123,6 +125,13 @@ type Styles struct {
123 // Sidebar
125 // Sidebar
124 SidebarFull lipgloss.Style
126 SidebarFull lipgloss.Style
125 SidebarCompact lipgloss.Style
127 SidebarCompact lipgloss.Style
128+
129+ // Initialize
130+ Initialize struct {
131+ Header lipgloss.Style
132+ Content lipgloss.Style
133+ Accent lipgloss.Style
134+ }
126}
135}
127
136
128func DefaultStyles() Styles {
137func DefaultStyles() Styles {
@@ -178,6 +187,8 @@ func DefaultStyles() Styles {
178
187
179 s := Styles{}
188 s := Styles{}
180
189
190+ s.Background = bgBase
191+
181 s.TextInput = textinput.Styles{
192 s.TextInput = textinput.Styles{
182 Focused: textinput.StyleState{
193 Focused: textinput.StyleState{
183 Text: base,
194 Text: base,
@@ -537,8 +548,8 @@ func DefaultStyles() Styles {
537 s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
548 s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
538
549
539 // Buttons
550 // Buttons
540− s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary)
551+ s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
541− s.ButtonUnselected = s.Base.Background(bgSubtle)
552+ s.ButtonBlur = s.Base.Background(bgSubtle)
542
553
543 // Borders
554 // Borders
544 s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
555 s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
@@ -562,6 +573,10 @@ func DefaultStyles() Styles {
562 s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
573 s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
563 s.SidebarCompact = s.SidebarFull.PaddingTop(0)
574 s.SidebarCompact = s.SidebarFull.PaddingTop(0)
564
575
576+ // Initialize
577+ s.Initialize.Header = s.Base
578+ s.Initialize.Content = s.Muted
579+ s.Initialize.Accent = s.Base.Foreground(greenDark)
565 return s
580 return s
566}
581}
567
582