intiial layout

Kujtim Hoxha created

Change summary

internal/tui/components/chat/editor.go       |  62 +++++
internal/tui/components/chat/messages.go     |  21 ++
internal/tui/components/chat/sidebar.go      |  21 ++
internal/tui/components/dialog/permission.go |   2 
internal/tui/components/repl/editor.go       |  48 ++--
internal/tui/layout/container.go             | 224 +++++++++++++++++++++
internal/tui/layout/single.go                |   4 
internal/tui/layout/split.go                 | 229 ++++++++++++++++++++++
internal/tui/page/chat.go                    |  30 ++
internal/tui/page/init.go                    |   2 
internal/tui/styles/styles.go                |  16 +
internal/tui/tui.go                          |   5 
12 files changed, 638 insertions(+), 26 deletions(-)

Detailed changes

internal/tui/components/chat/editor.go 🔗

@@ -0,0 +1,62 @@
+package chat
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/kujtimiihoxha/termai/internal/tui/layout"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type editorCmp struct {
+	textarea textarea.Model
+}
+
+func (m *editorCmp) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	m.textarea, cmd = m.textarea.Update(msg)
+	return m, cmd
+}
+
+func (m *editorCmp) View() string {
+	style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
+
+	return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+}
+
+func (m *editorCmp) SetSize(width, height int) {
+	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
+	m.textarea.SetHeight(height)
+}
+
+func (m *editorCmp) GetSize() (int, int) {
+	return m.textarea.Width(), m.textarea.Height()
+}
+
+func (m *editorCmp) BindingKeys() []key.Binding {
+	return layout.KeyMapToSlice(m.textarea.KeyMap)
+}
+
+func NewEditorCmp() tea.Model {
+	ti := textarea.New()
+	ti.Prompt = " "
+	ti.ShowLineNumbers = false
+	ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
+	ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
+	ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
+	ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+
+	ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
+	ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
+	ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
+	ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+	ti.Focus()
+	return &editorCmp{
+		textarea: ti,
+	}
+}

internal/tui/components/chat/messages.go 🔗

@@ -0,0 +1,21 @@
+package chat
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type messagesCmp struct{}
+
+func (m *messagesCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *messagesCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *messagesCmp) View() string {
+	return "Messages"
+}
+
+func NewMessagesCmp() tea.Model {
+	return &messagesCmp{}
+}

internal/tui/components/chat/sidebar.go 🔗

@@ -0,0 +1,21 @@
+package chat
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type sidebarCmp struct{}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *sidebarCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *sidebarCmp) View() string {
+	return "Sidebar"
+}
+
+func NewSidebarCmp() tea.Model {
+	return &sidebarCmp{}
+}

internal/tui/components/dialog/permission.go 🔗

@@ -441,7 +441,7 @@ func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
 		layout.WithSinglePaneBordered(true),
 		layout.WithSinglePaneFocusable(true),
 		layout.WithSinglePaneActiveColor(styles.Warning),
-		layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
+		layout.WithSinglePaneBorderText(map[layout.BorderPosition]string{
 			layout.TopMiddleBorder: " Permission Required ",
 		}),
 	)

internal/tui/components/repl/editor.go 🔗

@@ -156,30 +156,36 @@ func (m *editorCmp) Cancel() tea.Cmd {
 }
 
 func (m *editorCmp) Send() tea.Cmd {
-	return func() tea.Msg {
-		messages, err := m.app.Messages.List(m.sessionID)
-		if err != nil {
-			return util.ReportError(err)
-		}
-		if hasUnfinishedMessages(messages) {
-			return util.ReportWarn("Assistant is still working on the previous message")
-		}
-		a, err := agent.NewCoderAgent(m.app)
-		if err != nil {
-			return util.ReportError(err)
-		}
+	if m.cancelMessage != nil {
+		return util.ReportWarn("Assistant is still working on the previous message")
+	}
 
-		content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
-		ctx, cancel := context.WithCancel(m.app.Context)
-		m.cancelMessage = cancel
-		go func() {
-			defer cancel()
-			a.Generate(ctx, m.sessionID, content)
-			m.cancelMessage = nil
-		}()
+	messages, err := m.app.Messages.List(m.sessionID)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	if hasUnfinishedMessages(messages) {
+		return util.ReportWarn("Assistant is still working on the previous message")
+	}
+
+	a, err := agent.NewCoderAgent(m.app)
+	if err != nil {
+		return util.ReportError(err)
+	}
 
-		return m.editor.Reset()
+	content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
+	if len(content) == 0 {
+		return util.ReportWarn("Message is empty")
 	}
+	ctx, cancel := context.WithCancel(m.app.Context)
+	m.cancelMessage = cancel
+	go func() {
+		defer cancel()
+		a.Generate(ctx, m.sessionID, content)
+		m.cancelMessage = nil
+	}()
+
+	return m.editor.Reset()
 }
 
 func (m *editorCmp) View() string {

internal/tui/layout/container.go 🔗

@@ -0,0 +1,224 @@
+package layout
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type Container interface {
+	tea.Model
+	Sizeable
+}
+type container struct {
+	width  int
+	height int
+
+	content tea.Model
+
+	// Style options
+	paddingTop    int
+	paddingRight  int
+	paddingBottom int
+	paddingLeft   int
+
+	borderTop    bool
+	borderRight  bool
+	borderBottom bool
+	borderLeft   bool
+	borderStyle  lipgloss.Border
+	borderColor  lipgloss.TerminalColor
+
+	backgroundColor lipgloss.TerminalColor
+}
+
+func (c *container) Init() tea.Cmd {
+	return c.content.Init()
+}
+
+func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	u, cmd := c.content.Update(msg)
+	c.content = u
+	return c, cmd
+}
+
+func (c *container) View() string {
+	style := lipgloss.NewStyle()
+	width := c.width
+	height := c.height
+	// Apply background color if specified
+	if c.backgroundColor != nil {
+		style = style.Background(c.backgroundColor)
+	}
+
+	// Apply border if any side is enabled
+	if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
+		// Adjust width and height for borders
+		if c.borderTop {
+			height--
+		}
+		if c.borderBottom {
+			height--
+		}
+		if c.borderLeft {
+			width--
+		}
+		if c.borderRight {
+			width--
+		}
+		style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
+
+		// Apply border color if specified
+		if c.borderColor != nil {
+			style = style.BorderBackground(c.backgroundColor).BorderForeground(c.borderColor)
+		}
+	}
+	style = style.
+		Width(width).
+		Height(height).
+		PaddingTop(c.paddingTop).
+		PaddingRight(c.paddingRight).
+		PaddingBottom(c.paddingBottom).
+		PaddingLeft(c.paddingLeft)
+
+	return style.Render(c.content.View())
+}
+
+func (c *container) SetSize(width, height int) {
+	c.width = width
+	c.height = height
+
+	// If the content implements Sizeable, adjust its size to account for padding and borders
+	if sizeable, ok := c.content.(Sizeable); ok {
+		// Calculate horizontal space taken by padding and borders
+		horizontalSpace := c.paddingLeft + c.paddingRight
+		if c.borderLeft {
+			horizontalSpace++
+		}
+		if c.borderRight {
+			horizontalSpace++
+		}
+
+		// Calculate vertical space taken by padding and borders
+		verticalSpace := c.paddingTop + c.paddingBottom
+		if c.borderTop {
+			verticalSpace++
+		}
+		if c.borderBottom {
+			verticalSpace++
+		}
+
+		// Set content size with adjusted dimensions
+		contentWidth := max(0, width-horizontalSpace)
+		contentHeight := max(0, height-verticalSpace)
+		sizeable.SetSize(contentWidth, contentHeight)
+	}
+}
+
+func (c *container) GetSize() (int, int) {
+	return c.width, c.height
+}
+
+func (c *container) BindingKeys() []key.Binding {
+	if b, ok := c.content.(Bindings); ok {
+		return b.BindingKeys()
+	}
+	return []key.Binding{}
+}
+
+type ContainerOption func(*container)
+
+func NewContainer(content tea.Model, options ...ContainerOption) Container {
+	c := &container{
+		content:         content,
+		borderColor:     styles.BorderColor,
+		borderStyle:     lipgloss.NormalBorder(),
+		backgroundColor: styles.Background,
+	}
+
+	for _, option := range options {
+		option(c)
+	}
+
+	return c
+}
+
+// Padding options
+func WithPadding(top, right, bottom, left int) ContainerOption {
+	return func(c *container) {
+		c.paddingTop = top
+		c.paddingRight = right
+		c.paddingBottom = bottom
+		c.paddingLeft = left
+	}
+}
+
+func WithPaddingAll(padding int) ContainerOption {
+	return WithPadding(padding, padding, padding, padding)
+}
+
+func WithPaddingHorizontal(padding int) ContainerOption {
+	return func(c *container) {
+		c.paddingLeft = padding
+		c.paddingRight = padding
+	}
+}
+
+func WithPaddingVertical(padding int) ContainerOption {
+	return func(c *container) {
+		c.paddingTop = padding
+		c.paddingBottom = padding
+	}
+}
+
+func WithBorder(top, right, bottom, left bool) ContainerOption {
+	return func(c *container) {
+		c.borderTop = top
+		c.borderRight = right
+		c.borderBottom = bottom
+		c.borderLeft = left
+	}
+}
+
+func WithBorderAll() ContainerOption {
+	return WithBorder(true, true, true, true)
+}
+
+func WithBorderHorizontal() ContainerOption {
+	return WithBorder(true, false, true, false)
+}
+
+func WithBorderVertical() ContainerOption {
+	return WithBorder(false, true, false, true)
+}
+
+func WithBorderStyle(style lipgloss.Border) ContainerOption {
+	return func(c *container) {
+		c.borderStyle = style
+	}
+}
+
+func WithBorderColor(color lipgloss.TerminalColor) ContainerOption {
+	return func(c *container) {
+		c.borderColor = color
+	}
+}
+
+func WithRoundedBorder() ContainerOption {
+	return WithBorderStyle(lipgloss.RoundedBorder())
+}
+
+func WithThickBorder() ContainerOption {
+	return WithBorderStyle(lipgloss.ThickBorder())
+}
+
+func WithDoubleBorder() ContainerOption {
+	return WithBorderStyle(lipgloss.DoubleBorder())
+}
+
+func WithBackgroundColor(color lipgloss.TerminalColor) ContainerOption {
+	return func(c *container) {
+		c.backgroundColor = color
+	}
+}

internal/tui/layout/single.go 🔗

@@ -151,7 +151,7 @@ func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout
 	return layout
 }
 
-func WithSignlePaneSize(width, height int) SinglePaneOption {
+func WithSinglePaneSize(width, height int) SinglePaneOption {
 	return func(opts *singlePaneLayout) {
 		opts.width = width
 		opts.height = height
@@ -170,7 +170,7 @@ func WithSinglePaneBordered(bordered bool) SinglePaneOption {
 	}
 }
 
-func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
+func WithSinglePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
 	return func(opts *singlePaneLayout) {
 		opts.borderText = borderText
 	}

internal/tui/layout/split.go 🔗

@@ -0,0 +1,229 @@
+package layout
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type SplitPaneLayout interface {
+	tea.Model
+	Sizeable
+}
+
+type splitPaneLayout struct {
+	width         int
+	height        int
+	ratio         float64
+	verticalRatio float64
+
+	rightPanel  Container
+	leftPanel   Container
+	bottomPanel Container
+
+	backgroundColor lipgloss.TerminalColor
+}
+
+type SplitPaneOption func(*splitPaneLayout)
+
+func (s *splitPaneLayout) Init() tea.Cmd {
+	var cmds []tea.Cmd
+
+	if s.leftPanel != nil {
+		cmds = append(cmds, s.leftPanel.Init())
+	}
+
+	if s.rightPanel != nil {
+		cmds = append(cmds, s.rightPanel.Init())
+	}
+
+	if s.bottomPanel != nil {
+		cmds = append(cmds, s.bottomPanel.Init())
+	}
+
+	return tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		s.SetSize(msg.Width, msg.Height)
+		return s, nil
+	}
+
+	if s.rightPanel != nil {
+		u, cmd := s.rightPanel.Update(msg)
+		s.rightPanel = u.(Container)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	if s.leftPanel != nil {
+		u, cmd := s.leftPanel.Update(msg)
+		s.leftPanel = u.(Container)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	if s.bottomPanel != nil {
+		u, cmd := s.bottomPanel.Update(msg)
+		s.bottomPanel = u.(Container)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+
+	return s, tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) View() string {
+	var topSection string
+
+	if s.leftPanel != nil && s.rightPanel != nil {
+		leftView := s.leftPanel.View()
+		rightView := s.rightPanel.View()
+		topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
+	} else if s.leftPanel != nil {
+		topSection = s.leftPanel.View()
+	} else if s.rightPanel != nil {
+		topSection = s.rightPanel.View()
+	} else {
+		topSection = ""
+	}
+
+	var finalView string
+
+	if s.bottomPanel != nil && topSection != "" {
+		bottomView := s.bottomPanel.View()
+		finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
+	} else if s.bottomPanel != nil {
+		finalView = s.bottomPanel.View()
+	} else {
+		finalView = topSection
+	}
+
+	if s.backgroundColor != nil && finalView != "" {
+		style := lipgloss.NewStyle().
+			Width(s.width).
+			Height(s.height).
+			Background(s.backgroundColor)
+
+		return style.Render(finalView)
+	}
+
+	return finalView
+}
+
+func (s *splitPaneLayout) SetSize(width, height int) {
+	s.width = width
+	s.height = height
+
+	var topHeight, bottomHeight int
+	if s.bottomPanel != nil {
+		topHeight = int(float64(height) * s.verticalRatio)
+		bottomHeight = height - topHeight
+	} else {
+		topHeight = height
+		bottomHeight = 0
+	}
+
+	var leftWidth, rightWidth int
+	if s.leftPanel != nil && s.rightPanel != nil {
+		leftWidth = int(float64(width) * s.ratio)
+		rightWidth = width - leftWidth
+	} else if s.leftPanel != nil {
+		leftWidth = width
+		rightWidth = 0
+	} else if s.rightPanel != nil {
+		leftWidth = 0
+		rightWidth = width
+	}
+
+	if s.leftPanel != nil {
+		s.leftPanel.SetSize(leftWidth, topHeight)
+	}
+
+	if s.rightPanel != nil {
+		s.rightPanel.SetSize(rightWidth, topHeight)
+	}
+
+	if s.bottomPanel != nil {
+		s.bottomPanel.SetSize(width, bottomHeight)
+	}
+}
+
+func (s *splitPaneLayout) GetSize() (int, int) {
+	return s.width, s.height
+}
+
+func (s *splitPaneLayout) BindingKeys() []key.Binding {
+	keys := []key.Binding{}
+	if s.leftPanel != nil {
+		if b, ok := s.leftPanel.(Bindings); ok {
+			keys = append(keys, b.BindingKeys()...)
+		}
+	}
+	if s.rightPanel != nil {
+		if b, ok := s.rightPanel.(Bindings); ok {
+			keys = append(keys, b.BindingKeys()...)
+		}
+	}
+	if s.bottomPanel != nil {
+		if b, ok := s.bottomPanel.(Bindings); ok {
+			keys = append(keys, b.BindingKeys()...)
+		}
+	}
+	return keys
+}
+
+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
+	layout := &splitPaneLayout{
+		ratio:           0.7,
+		verticalRatio:   0.9, // Default 80% for top section, 20% for bottom
+		backgroundColor: styles.Background,
+	}
+	for _, option := range options {
+		option(layout)
+	}
+	return layout
+}
+
+func WithLeftPanel(panel Container) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.leftPanel = panel
+	}
+}
+
+func WithRightPanel(panel Container) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.rightPanel = panel
+	}
+}
+
+func WithRatio(ratio float64) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.ratio = ratio
+	}
+}
+
+func WithSplitBackgroundColor(color lipgloss.TerminalColor) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.backgroundColor = color
+	}
+}
+
+func WithBottomPanel(panel Container) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.bottomPanel = panel
+	}
+}
+
+func WithVerticalRatio(ratio float64) SplitPaneOption {
+	return func(s *splitPaneLayout) {
+		s.verticalRatio = ratio
+	}
+}

internal/tui/page/chat.go 🔗

@@ -0,0 +1,30 @@
+package page
+
+import (
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/kujtimiihoxha/termai/internal/app"
+	"github.com/kujtimiihoxha/termai/internal/tui/components/chat"
+	"github.com/kujtimiihoxha/termai/internal/tui/layout"
+)
+
+var ChatPage PageID = "chat"
+
+func NewChatPage(app *app.App) tea.Model {
+	messagesContainer := layout.NewContainer(
+		chat.NewMessagesCmp(),
+		layout.WithPadding(1, 1, 1, 1),
+	)
+	sidebarContainer := layout.NewContainer(
+		chat.NewSidebarCmp(),
+		layout.WithPadding(1, 1, 1, 1),
+	)
+	editorContainer := layout.NewContainer(
+		chat.NewEditorCmp(),
+		layout.WithBorder(true, false, false, false),
+	)
+	return layout.NewSplitPane(
+		layout.WithRightPanel(sidebarContainer),
+		layout.WithLeftPanel(messagesContainer),
+		layout.WithBottomPanel(editorContainer),
+	)
+}

internal/tui/page/init.go 🔗

@@ -299,7 +299,7 @@ func NewInitPage() tea.Model {
 		initModel,
 		layout.WithSinglePaneFocusable(true),
 		layout.WithSinglePaneBordered(true),
-		layout.WithSignlePaneBorderText(
+		layout.WithSinglePaneBorderText(
 			map[layout.BorderPosition]string{
 				layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
 			},

internal/tui/styles/styles.go 🔗

@@ -10,6 +10,22 @@ var (
 	dark  = catppuccin.Mocha
 )
 
+// NEW STYLES
+var (
+	Background = lipgloss.AdaptiveColor{
+		Dark:  "#212121",
+		Light: "#212121",
+	}
+	BackgroundDarker = lipgloss.AdaptiveColor{
+		Dark:  "#181818",
+		Light: "#181818",
+	}
+	BorderColor = lipgloss.AdaptiveColor{
+		Dark:  "#4b4c5c",
+		Light: "#4b4c5c",
+	}
+)
+
 var (
 	Regular = lipgloss.NewStyle()
 	Bold    = Regular.Bold(true)

internal/tui/tui.go 🔗

@@ -200,6 +200,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			case key.Matches(msg, keys.Logs):
 				return a, a.moveToPage(page.LogsPage)
+			case msg.String() == "O":
+				return a, a.moveToPage(page.ReplPage)
 			case key.Matches(msg, keys.Help):
 				a.ToggleHelp()
 				return a, nil
@@ -292,7 +294,7 @@ func New(app *app.App) tea.Model {
 	// homedir, _ := os.UserHomeDir()
 	// configPath := filepath.Join(homedir, ".termai.yaml")
 	//
-	startPage := page.ReplPage
+	startPage := page.ChatPage
 	// if _, err := os.Stat(configPath); os.IsNotExist(err) {
 	// 	startPage = page.InitPage
 	// }
@@ -305,6 +307,7 @@ func New(app *app.App) tea.Model {
 		dialog:      core.NewDialogCmp(),
 		app:         app,
 		pages: map[page.PageID]tea.Model{
+			page.ChatPage: page.NewChatPage(app),
 			page.LogsPage: page.NewLogsPage(),
 			page.InitPage: page.NewInitPage(),
 			page.ReplPage: page.NewReplPage(app),