Detailed changes
@@ -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,
+ }
+}
@@ -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{}
+}
@@ -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{}
+}
@@ -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 ",
}),
)
@@ -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 {
@@ -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
+ }
+}
@@ -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
}
@@ -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
+ }
+}
@@ -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),
+ )
+}
@@ -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",
},
@@ -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)
@@ -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),