summaryrefslogtreecommitdiffhomepage
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/components/core/status.go0
-rw-r--r--internal/tui/components/logs/table.go131
-rw-r--r--internal/tui/components/repl/editor.go21
-rw-r--r--internal/tui/components/repl/messages.go21
-rw-r--r--internal/tui/components/repl/threads.go21
-rw-r--r--internal/tui/layout/bento.go361
-rw-r--r--internal/tui/layout/border.go99
-rw-r--r--internal/tui/layout/layout.go39
-rw-r--r--internal/tui/layout/single.go172
-rw-r--r--internal/tui/page/init.go37
-rw-r--r--internal/tui/page/logs.go25
-rw-r--r--internal/tui/page/page.go3
-rw-r--r--internal/tui/page/repl.go19
-rw-r--r--internal/tui/styles/icons.go12
-rw-r--r--internal/tui/styles/markdown.go498
-rw-r--r--internal/tui/styles/styles.go121
-rw-r--r--internal/tui/tui.go99
17 files changed, 1679 insertions, 0 deletions
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/internal/tui/components/core/status.go
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
new file mode 100644
index 000000000..0387af83b
--- /dev/null
+++ b/internal/tui/components/logs/table.go
@@ -0,0 +1,131 @@
+package logs
+
+import (
+ "encoding/json"
+ "slices"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/table"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/termai/internal/logging"
+ "github.com/kujtimiihoxha/termai/internal/pubsub"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
+)
+
+type TableComponent interface {
+ tea.Model
+ layout.Focusable
+ layout.Sizeable
+ layout.Bindings
+}
+
+var logger = logging.Get()
+
+type tableCmp struct {
+ table table.Model
+}
+
+func (i *tableCmp) Init() tea.Cmd {
+ i.setRows()
+ return nil
+}
+
+func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if i.table.Focused() {
+ switch msg := msg.(type) {
+ case pubsub.Event[logging.Message]:
+ i.setRows()
+ return i, nil
+ case tea.KeyMsg:
+ if msg.String() == "ctrl+s" {
+ logger.Info("Saving logs...",
+ "rows", len(i.table.Rows()),
+ )
+ }
+ }
+ t, cmd := i.table.Update(msg)
+ i.table = t
+ return i, cmd
+ }
+ return i, nil
+}
+
+func (i *tableCmp) View() string {
+ return i.table.View()
+}
+
+func (i *tableCmp) Blur() tea.Cmd {
+ i.table.Blur()
+ return nil
+}
+
+func (i *tableCmp) Focus() tea.Cmd {
+ i.table.Focus()
+ return nil
+}
+
+func (i *tableCmp) IsFocused() bool {
+ return i.table.Focused()
+}
+
+func (i *tableCmp) GetSize() (int, int) {
+ return i.table.Width(), i.table.Height()
+}
+
+func (i *tableCmp) SetSize(width int, height int) {
+ i.table.SetWidth(width)
+ i.table.SetHeight(height)
+ cloumns := i.table.Columns()
+ for i, col := range cloumns {
+ col.Width = (width / len(cloumns)) - 2
+ cloumns[i] = col
+ }
+ i.table.SetColumns(cloumns)
+}
+
+func (i *tableCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(i.table.KeyMap)
+}
+
+func (i *tableCmp) setRows() {
+ rows := []table.Row{}
+
+ logs := logger.List()
+ slices.SortFunc(logs, func(a, b logging.Message) int {
+ if a.Time.Before(b.Time) {
+ return 1
+ }
+ if a.Time.After(b.Time) {
+ return -1
+ }
+ return 0
+ })
+
+ for _, log := range logs {
+ bm, _ := json.Marshal(log.Attributes)
+
+ row := table.Row{
+ log.Time.Format("15:04:05"),
+ log.Level,
+ log.Message,
+ string(bm),
+ }
+ rows = append(rows, row)
+ }
+ i.table.SetRows(rows)
+}
+
+func NewLogsTable() TableComponent {
+ columns := []table.Column{
+ {Title: "Time", Width: 4},
+ {Title: "Level", Width: 10},
+ {Title: "Message", Width: 10},
+ {Title: "Attributes", Width: 10},
+ }
+ tableModel := table.New(
+ table.WithColumns(columns),
+ )
+ return &tableCmp{
+ table: tableModel,
+ }
+}
diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go
new file mode 100644
index 000000000..8a04889ab
--- /dev/null
+++ b/internal/tui/components/repl/editor.go
@@ -0,0 +1,21 @@
+package repl
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type editorCmp struct{}
+
+func (i *editorCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
+ return i, nil
+}
+
+func (i *editorCmp) View() string {
+ return "Editor"
+}
+
+func NewEditorCmp() tea.Model {
+ return &editorCmp{}
+}
diff --git a/internal/tui/components/repl/messages.go b/internal/tui/components/repl/messages.go
new file mode 100644
index 000000000..edef26502
--- /dev/null
+++ b/internal/tui/components/repl/messages.go
@@ -0,0 +1,21 @@
+package repl
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type messagesCmp struct{}
+
+func (i *messagesCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (i *messagesCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
+ return i, nil
+}
+
+func (i *messagesCmp) View() string {
+ return "Messages"
+}
+
+func NewMessagesCmp() tea.Model {
+ return &messagesCmp{}
+}
diff --git a/internal/tui/components/repl/threads.go b/internal/tui/components/repl/threads.go
new file mode 100644
index 000000000..aa2bc080b
--- /dev/null
+++ b/internal/tui/components/repl/threads.go
@@ -0,0 +1,21 @@
+package repl
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type threadsCmp struct{}
+
+func (i *threadsCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (i *threadsCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
+ return i, nil
+}
+
+func (i *threadsCmp) View() string {
+ return "Threads"
+}
+
+func NewThreadsCmp() tea.Model {
+ return &threadsCmp{}
+}
diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go
new file mode 100644
index 000000000..7d4c070df
--- /dev/null
+++ b/internal/tui/layout/bento.go
@@ -0,0 +1,361 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type paneID string
+
+const (
+ BentoLeftPane paneID = "left"
+ BentoRightTopPane paneID = "right-top"
+ BentoRightBottomPane paneID = "right-bottom"
+)
+
+type BentoPanes map[paneID]tea.Model
+
+const (
+ defaultLeftWidthRatio = 0.2
+ defaultRightTopHeightRatio = 0.85
+
+ minLeftWidth = 10
+ minRightBottomHeight = 10
+)
+
+type BentoLayout interface {
+ tea.Model
+ Sizeable
+ Bindings
+}
+
+type BentoKeyBindings struct {
+ SwitchPane key.Binding
+ SwitchPaneBack key.Binding
+ HideCurrentPane key.Binding
+ ShowAllPanes key.Binding
+}
+
+var defaultBentoKeyBindings = BentoKeyBindings{
+ SwitchPane: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch pane"),
+ ),
+ SwitchPaneBack: key.NewBinding(
+ key.WithKeys("shift+tab"),
+ key.WithHelp("shift+tab", "switch pane back"),
+ ),
+ HideCurrentPane: key.NewBinding(
+ key.WithKeys("X"),
+ key.WithHelp("X", "hide current pane"),
+ ),
+ ShowAllPanes: key.NewBinding(
+ key.WithKeys("R"),
+ key.WithHelp("R", "show all panes"),
+ ),
+}
+
+type bentoLayout struct {
+ width int
+ height int
+
+ leftWidthRatio float64
+ rightTopHeightRatio float64
+
+ currentPane paneID
+ panes map[paneID]SinglePaneLayout
+ hiddenPanes map[paneID]bool
+}
+
+func (b *bentoLayout) GetSize() (int, int) {
+ return b.width, b.height
+}
+
+func (b bentoLayout) Init() tea.Cmd {
+ return nil
+}
+
+func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ b.SetSize(msg.Width, msg.Height)
+ return b, nil
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
+ return b, b.SwitchPane(false)
+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
+ return b, b.SwitchPane(true)
+ case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
+ return b, b.HidePane(b.currentPane)
+ case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
+ for id := range b.hiddenPanes {
+ delete(b.hiddenPanes, id)
+ }
+ b.SetSize(b.width, b.height)
+ return b, nil
+ }
+ }
+
+ if pane, ok := b.panes[b.currentPane]; ok {
+ u, cmd := pane.Update(msg)
+ b.panes[b.currentPane] = u.(SinglePaneLayout)
+ return b, cmd
+ }
+ return b, nil
+}
+
+func (b bentoLayout) View() string {
+ if b.width <= 0 || b.height <= 0 {
+ return ""
+ }
+
+ for id, pane := range b.panes {
+ if b.currentPane == id {
+ pane.Focus()
+ } else {
+ pane.Blur()
+ }
+ }
+
+ leftVisible := false
+ rightTopVisible := false
+ rightBottomVisible := false
+
+ var leftPane, rightTopPane, rightBottomPane string
+
+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ leftPane = pane.View()
+ leftVisible = true
+ }
+
+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ rightTopPane = pane.View()
+ rightTopVisible = true
+ }
+
+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ rightBottomPane = pane.View()
+ rightBottomVisible = true
+ }
+
+ if leftVisible {
+ if rightTopVisible || rightBottomVisible {
+ rightSection := ""
+ if rightTopVisible && rightBottomVisible {
+ rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
+ } else if rightTopVisible {
+ rightSection = rightTopPane
+ } else {
+ rightSection = rightBottomPane
+ }
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
+ lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
+ )
+ } else {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
+ }
+ } else if rightTopVisible || rightBottomVisible {
+ if rightTopVisible && rightBottomVisible {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
+ lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
+ )
+ } else if rightTopVisible {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
+ } else {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
+ }
+ }
+ return ""
+}
+
+func (b *bentoLayout) SetSize(width int, height int) {
+ if width < 0 || height < 0 {
+ return
+ }
+ b.width = width
+ b.height = height
+
+ // Check which panes are available
+ leftExists := false
+ rightTopExists := false
+ rightBottomExists := false
+
+ if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ leftExists = true
+ }
+ if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ rightTopExists = true
+ }
+ if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ rightBottomExists = true
+ }
+
+ leftWidth := 0
+ rightWidth := 0
+ rightTopHeight := 0
+ rightBottomHeight := 0
+
+ if leftExists && (rightTopExists || rightBottomExists) {
+ leftWidth = int(float64(width) * b.leftWidthRatio)
+ if leftWidth < minLeftWidth && width >= minLeftWidth {
+ leftWidth = minLeftWidth
+ }
+ rightWidth = width - leftWidth
+
+ if rightTopExists && rightBottomExists {
+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
+ rightBottomHeight = height - rightTopHeight
+
+ // Ensure minimum height for bottom pane
+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
+ rightBottomHeight = minRightBottomHeight
+ rightTopHeight = height - rightBottomHeight
+ }
+ } else if rightTopExists {
+ rightTopHeight = height
+ } else if rightBottomExists {
+ rightBottomHeight = height
+ }
+ } else if leftExists {
+ leftWidth = width
+ } else if rightTopExists || rightBottomExists {
+ rightWidth = width
+
+ if rightTopExists && rightBottomExists {
+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
+ rightBottomHeight = height - rightTopHeight
+
+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
+ rightBottomHeight = minRightBottomHeight
+ rightTopHeight = height - rightBottomHeight
+ }
+ } else if rightTopExists {
+ rightTopHeight = height
+ } else if rightBottomExists {
+ rightBottomHeight = height
+ }
+ }
+
+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ pane.SetSize(leftWidth, height)
+ }
+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ pane.SetSize(rightWidth, rightTopHeight)
+ }
+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ pane.SetSize(rightWidth, rightBottomHeight)
+ }
+}
+
+func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
+ if len(b.panes)-len(b.hiddenPanes) == 1 {
+ return nil
+ }
+ if _, ok := b.panes[pane]; ok {
+ b.hiddenPanes[pane] = true
+ }
+ b.SetSize(b.width, b.height)
+ return b.SwitchPane(false)
+}
+
+func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
+ if back {
+ switch b.currentPane {
+ case BentoLeftPane:
+ b.currentPane = BentoRightBottomPane
+ case BentoRightTopPane:
+ b.currentPane = BentoLeftPane
+ case BentoRightBottomPane:
+ b.currentPane = BentoRightTopPane
+ }
+ } else {
+ switch b.currentPane {
+ case BentoLeftPane:
+ b.currentPane = BentoRightTopPane
+ case BentoRightTopPane:
+ b.currentPane = BentoRightBottomPane
+ case BentoRightBottomPane:
+ b.currentPane = BentoLeftPane
+ }
+ }
+
+ var cmds []tea.Cmd
+ for id, pane := range b.panes {
+ if _, ok := b.hiddenPanes[id]; ok {
+ continue
+ }
+ if id == b.currentPane {
+ cmds = append(cmds, pane.Focus())
+ } else {
+ cmds = append(cmds, pane.Blur())
+ }
+ }
+
+ return tea.Batch(cmds...)
+}
+
+func (s *bentoLayout) BindingKeys() []key.Binding {
+ bindings := KeyMapToSlice(defaultBentoKeyBindings)
+ if b, ok := s.panes[s.currentPane].(Bindings); ok {
+ bindings = append(bindings, b.BindingKeys()...)
+ }
+ return bindings
+}
+
+type BentoLayoutOption func(*bentoLayout)
+
+func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
+ p := make(map[paneID]SinglePaneLayout, len(panes))
+ for id, pane := range panes {
+ // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
+ if _, ok := pane.(SinglePaneLayout); !ok {
+ p[id] = NewSinglePane(
+ pane,
+ WithSinglePaneFocusable(true),
+ WithSinglePaneBordered(true),
+ )
+ } else {
+ p[id] = pane.(SinglePaneLayout)
+ }
+ }
+ if len(p) == 0 {
+ panic("no panes provided for BentoLayout")
+ }
+ layout := &bentoLayout{
+ panes: p,
+ hiddenPanes: make(map[paneID]bool),
+ currentPane: BentoLeftPane,
+ leftWidthRatio: defaultLeftWidthRatio,
+ rightTopHeightRatio: defaultRightTopHeightRatio,
+ }
+
+ for _, opt := range opts {
+ opt(layout)
+ }
+
+ return layout
+}
+
+func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ if ratio > 0 && ratio < 1 {
+ b.leftWidthRatio = ratio
+ }
+ }
+}
+
+func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ if ratio > 0 && ratio < 1 {
+ b.rightTopHeightRatio = ratio
+ }
+ }
+}
+
+func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ b.currentPane = pane
+ }
+}
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
new file mode 100644
index 000000000..d1b334b32
--- /dev/null
+++ b/internal/tui/layout/border.go
@@ -0,0 +1,99 @@
+package layout
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type BorderPosition int
+
+const (
+ TopLeftBorder BorderPosition = iota
+ TopMiddleBorder
+ TopRightBorder
+ BottomLeftBorder
+ BottomMiddleBorder
+ BottomRightBorder
+)
+
+var (
+ ActiveBorder = styles.Blue
+ InactivePreviewBorder = styles.Grey
+)
+
+func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
+ if embeddedText == nil {
+ embeddedText = make(map[BorderPosition]string)
+ }
+ var (
+ thickness = map[bool]lipgloss.Border{
+ true: lipgloss.Border(lipgloss.ThickBorder()),
+ false: lipgloss.Border(lipgloss.NormalBorder()),
+ }
+ color = map[bool]lipgloss.TerminalColor{
+ true: ActiveBorder,
+ false: InactivePreviewBorder,
+ }
+ border = thickness[active]
+ style = lipgloss.NewStyle().Foreground(color[active])
+ width = lipgloss.Width(content)
+ )
+
+ encloseInSquareBrackets := func(text string) string {
+ if text != "" {
+ return fmt.Sprintf("%s%s%s",
+ style.Render(border.TopRight),
+ text,
+ style.Render(border.TopLeft),
+ )
+ }
+ return text
+ }
+ buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
+ leftText = encloseInSquareBrackets(leftText)
+ middleText = encloseInSquareBrackets(middleText)
+ rightText = encloseInSquareBrackets(rightText)
+ // Calculate length of border between embedded texts
+ remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
+ leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
+ rightBorderLen := max(0, remaining-leftBorderLen)
+ // Then construct border string
+ s := leftText +
+ style.Render(strings.Repeat(inbetween, leftBorderLen)) +
+ middleText +
+ style.Render(strings.Repeat(inbetween, rightBorderLen)) +
+ rightText
+ // Make it fit in the space available between the two corners.
+ s = lipgloss.NewStyle().
+ Inline(true).
+ MaxWidth(width).
+ Render(s)
+ // Add the corners
+ return style.Render(leftCorner) + s + style.Render(rightCorner)
+ }
+ // Stack top border, content and horizontal borders, and bottom border.
+ return strings.Join([]string{
+ buildHorizontalBorder(
+ embeddedText[TopLeftBorder],
+ embeddedText[TopMiddleBorder],
+ embeddedText[TopRightBorder],
+ border.TopLeft,
+ border.Top,
+ border.TopRight,
+ ),
+ lipgloss.NewStyle().
+ BorderForeground(color[active]).
+ Border(border, false, true, false, true).Render(content),
+ buildHorizontalBorder(
+ embeddedText[BottomLeftBorder],
+ embeddedText[BottomMiddleBorder],
+ embeddedText[BottomRightBorder],
+ border.BottomLeft,
+ border.Bottom,
+ border.BottomRight,
+ ),
+ }, "\n")
+}
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
new file mode 100644
index 000000000..2f17c4a0e
--- /dev/null
+++ b/internal/tui/layout/layout.go
@@ -0,0 +1,39 @@
+package layout
+
+import (
+ "reflect"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Focusable interface {
+ Focus() tea.Cmd
+ Blur() tea.Cmd
+ IsFocused() bool
+}
+
+type Bordered interface {
+ BorderText() map[BorderPosition]string
+}
+
+type Sizeable interface {
+ SetSize(width, height int)
+ GetSize() (int, int)
+}
+
+type Bindings interface {
+ BindingKeys() []key.Binding
+}
+
+func KeyMapToSlice(t any) (bindings []key.Binding) {
+ typ := reflect.TypeOf(t)
+ if typ.Kind() != reflect.Struct {
+ return nil
+ }
+ for i := range typ.NumField() {
+ v := reflect.ValueOf(t).Field(i)
+ bindings = append(bindings, v.Interface().(key.Binding))
+ }
+ return
+}
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
new file mode 100644
index 000000000..230e45458
--- /dev/null
+++ b/internal/tui/layout/single.go
@@ -0,0 +1,172 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type SinglePaneLayout interface {
+ tea.Model
+ Focusable
+ Sizeable
+ Bindings
+}
+
+type singlePaneLayout struct {
+ width int
+ height int
+
+ focusable bool
+ focused bool
+
+ bordered bool
+ borderText map[BorderPosition]string
+
+ content tea.Model
+
+ padding []int
+}
+
+type SinglePaneOption func(*singlePaneLayout)
+
+func (s singlePaneLayout) Init() tea.Cmd {
+ return s.content.Init()
+}
+
+func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ s.SetSize(msg.Width, msg.Height)
+ return s, nil
+ }
+ u, cmd := s.content.Update(msg)
+ s.content = u
+ return s, cmd
+}
+
+func (s singlePaneLayout) View() string {
+ style := lipgloss.NewStyle().Width(s.width).Height(s.height)
+ if s.bordered {
+ style = style.Width(s.width).Height(s.height)
+ }
+ if s.padding != nil {
+ style = style.Padding(s.padding...)
+ }
+ content := style.Render(s.content.View())
+ if s.bordered {
+ if s.borderText == nil {
+ s.borderText = map[BorderPosition]string{}
+ }
+ if bordered, ok := s.content.(Bordered); ok {
+ s.borderText = bordered.BorderText()
+ }
+ return Borderize(content, s.focused, s.borderText)
+ }
+ return content
+}
+
+func (s *singlePaneLayout) Blur() tea.Cmd {
+ if s.focusable {
+ s.focused = false
+ }
+ if blurable, ok := s.content.(Focusable); ok {
+ return blurable.Blur()
+ }
+ return nil
+}
+
+func (s *singlePaneLayout) Focus() tea.Cmd {
+ if s.focusable {
+ s.focused = true
+ }
+ if focusable, ok := s.content.(Focusable); ok {
+ return focusable.Focus()
+ }
+ return nil
+}
+
+func (s *singlePaneLayout) SetSize(width, height int) {
+ s.width = width
+ s.height = height
+ if s.bordered {
+ s.width -= 2
+ s.height -= 2
+ }
+ if s.padding != nil {
+ if len(s.padding) == 1 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[0] * 2
+ } else if len(s.padding) == 2 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[1] * 2
+ } else if len(s.padding) == 3 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[1] + s.padding[2]
+ } else if len(s.padding) == 4 {
+ s.width -= s.padding[0] + s.padding[2]
+ s.height -= s.padding[1] + s.padding[3]
+ }
+ }
+ if s.content != nil {
+ if c, ok := s.content.(Sizeable); ok {
+ c.SetSize(s.width, s.height)
+ }
+ }
+}
+
+func (s *singlePaneLayout) IsFocused() bool {
+ return s.focused
+}
+
+func (s *singlePaneLayout) GetSize() (int, int) {
+ return s.width, s.height
+}
+
+func (s *singlePaneLayout) BindingKeys() []key.Binding {
+ if b, ok := s.content.(Bindings); ok {
+ return b.BindingKeys()
+ }
+ return []key.Binding{}
+}
+
+func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
+ layout := &singlePaneLayout{
+ content: content,
+ }
+ for _, opt := range opts {
+ opt(layout)
+ }
+ return layout
+}
+
+func WithSignlePaneSize(width, height int) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.width = width
+ opts.height = height
+ }
+}
+
+func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.focusable = focusable
+ }
+}
+
+func WithSinglePaneBordered(bordered bool) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.bordered = bordered
+ }
+}
+
+func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.borderText = borderText
+ }
+}
+
+func WithSinglePanePadding(padding ...int) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.padding = padding
+ }
+}
diff --git a/internal/tui/page/init.go b/internal/tui/page/init.go
new file mode 100644
index 000000000..884aaf9f9
--- /dev/null
+++ b/internal/tui/page/init.go
@@ -0,0 +1,37 @@
+package page
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
+)
+
+var InitPage PageID = "init"
+
+type initPage struct {
+ layout layout.SinglePaneLayout
+}
+
+func (i initPage) Init() tea.Cmd {
+ return nil
+}
+
+func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
+ return i, nil
+}
+
+func (i initPage) View() string {
+ return "Initializing..."
+}
+
+func NewInitPage() tea.Model {
+ return layout.NewSinglePane(
+ &initPage{},
+ layout.WithSinglePaneFocusable(true),
+ layout.WithSinglePaneBordered(true),
+ layout.WithSignlePaneBorderText(
+ map[layout.BorderPosition]string{
+ layout.TopMiddleBorder: "Welcome to termai",
+ },
+ ),
+ )
+}
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
new file mode 100644
index 000000000..17b1f2340
--- /dev/null
+++ b/internal/tui/page/logs.go
@@ -0,0 +1,25 @@
+package page
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/termai/internal/tui/components/logs"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
+)
+
+var LogsPage PageID = "logs"
+
+func NewLogsPage() tea.Model {
+ p := layout.NewSinglePane(
+ logs.NewLogsTable(),
+ layout.WithSinglePaneFocusable(true),
+ layout.WithSinglePaneBordered(true),
+ layout.WithSignlePaneBorderText(
+ map[layout.BorderPosition]string{
+ layout.TopMiddleBorder: "Logs",
+ },
+ ),
+ layout.WithSinglePanePadding(1),
+ )
+ p.Focus()
+ return p
+}
diff --git a/internal/tui/page/page.go b/internal/tui/page/page.go
new file mode 100644
index 000000000..578e0aa9a
--- /dev/null
+++ b/internal/tui/page/page.go
@@ -0,0 +1,3 @@
+package page
+
+type PageID string
diff --git a/internal/tui/page/repl.go b/internal/tui/page/repl.go
new file mode 100644
index 000000000..829b6f545
--- /dev/null
+++ b/internal/tui/page/repl.go
@@ -0,0 +1,19 @@
+package page
+
+import (
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
+)
+
+var ReplPage PageID = "repl"
+
+func NewReplPage() tea.Model {
+ return layout.NewBentoLayout(
+ layout.BentoPanes{
+ layout.BentoLeftPane: repl.NewThreadsCmp(),
+ layout.BentoRightTopPane: repl.NewMessagesCmp(),
+ layout.BentoRightBottomPane: repl.NewEditorCmp(),
+ },
+ )
+}
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
new file mode 100644
index 000000000..81a55aa07
--- /dev/null
+++ b/internal/tui/styles/icons.go
@@ -0,0 +1,12 @@
+package styles
+
+const (
+ SessionsIcon string = "󰧑"
+ ChatIcon string = "󰭹"
+
+ BotIcon string = "󰚩"
+ ToolIcon string = ""
+ UserIcon string = ""
+
+ SleepIcon string = "󰒲"
+)
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
new file mode 100644
index 000000000..cadb9a2e3
--- /dev/null
+++ b/internal/tui/styles/markdown.go
@@ -0,0 +1,498 @@
+package styles
+
+import (
+ "github.com/charmbracelet/glamour/ansi"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const defaultMargin = 2
+
+// Helper functions for style pointers
+func boolPtr(b bool) *bool { return &b }
+func stringPtr(s string) *string { return &s }
+func uintPtr(u uint) *uint { return &u }
+
+// CatppuccinMarkdownStyle is the Catppuccin Mocha style for Glamour markdown rendering.
+func CatppuccinMarkdownStyle() ansi.StyleConfig {
+ isDark := lipgloss.HasDarkBackground()
+ if isDark {
+ return catppuccinDark
+ }
+ return catppuccinLight
+}
+
+var catppuccinDark = ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "\n",
+ BlockSuffix: "\n",
+ Color: stringPtr(dark.Text().Hex),
+ },
+ Margin: uintPtr(defaultMargin),
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(dark.Yellow().Hex),
+ Italic: boolPtr(true),
+ Prefix: "┃ ",
+ },
+ Indent: uintPtr(1),
+ Margin: uintPtr(defaultMargin),
+ },
+ List: ansi.StyleList{
+ LevelIndent: defaultMargin,
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(dark.Text().Hex),
+ },
+ },
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockSuffix: "\n",
+ Color: stringPtr(dark.Mauve().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "# ",
+ Color: stringPtr(dark.Lavender().Hex),
+ Bold: boolPtr(true),
+ BlockPrefix: "\n",
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "## ",
+ Color: stringPtr(dark.Mauve().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "### ",
+ Color: stringPtr(dark.Pink().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "#### ",
+ Color: stringPtr(dark.Flamingo().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "##### ",
+ Color: stringPtr(dark.Rosewater().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "###### ",
+ Color: stringPtr(dark.Rosewater().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ CrossedOut: boolPtr(true),
+ Color: stringPtr(dark.Overlay1().Hex),
+ },
+ Emph: ansi.StylePrimitive{
+ Color: stringPtr(dark.Yellow().Hex),
+ Italic: boolPtr(true),
+ },
+ Strong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ Color: stringPtr(dark.Peach().Hex),
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ Color: stringPtr(dark.Overlay0().Hex),
+ Format: "\n─────────────────────────────────────────\n",
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ Color: stringPtr(dark.Blue().Hex),
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ Color: stringPtr(dark.Sky().Hex),
+ },
+ Task: ansi.StyleTask{
+ StylePrimitive: ansi.StylePrimitive{},
+ Ticked: "[✓] ",
+ Unticked: "[ ] ",
+ },
+ Link: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sky().Hex),
+ Underline: boolPtr(true),
+ },
+ LinkText: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ Bold: boolPtr(true),
+ },
+ Image: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sapphire().Hex),
+ Underline: boolPtr(true),
+ Format: "🖼 {{.text}}",
+ },
+ ImageText: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ Format: "{{.text}}",
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(dark.Green().Hex),
+ Prefix: " ",
+ Suffix: " ",
+ },
+ },
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Color: stringPtr(dark.Text().Hex),
+ },
+
+ Margin: uintPtr(defaultMargin),
+ },
+ Chroma: &ansi.Chroma{
+ Text: ansi.StylePrimitive{
+ Color: stringPtr(dark.Text().Hex),
+ },
+ Error: ansi.StylePrimitive{
+ Color: stringPtr(dark.Text().Hex),
+ },
+ Comment: ansi.StylePrimitive{
+ Color: stringPtr(dark.Overlay1().Hex),
+ },
+ CommentPreproc: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ Keyword: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ KeywordReserved: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ KeywordNamespace: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ KeywordType: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sky().Hex),
+ },
+ Operator: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ Punctuation: ansi.StylePrimitive{
+ Color: stringPtr(dark.Text().Hex),
+ },
+ Name: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sky().Hex),
+ },
+ NameBuiltin: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sky().Hex),
+ },
+ NameTag: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ NameAttribute: ansi.StylePrimitive{
+ Color: stringPtr(dark.Green().Hex),
+ },
+ NameClass: ansi.StylePrimitive{
+ Color: stringPtr(dark.Sky().Hex),
+ },
+ NameConstant: ansi.StylePrimitive{
+ Color: stringPtr(dark.Mauve().Hex),
+ },
+ NameDecorator: ansi.StylePrimitive{
+ Color: stringPtr(dark.Green().Hex),
+ },
+ NameFunction: ansi.StylePrimitive{
+ Color: stringPtr(dark.Green().Hex),
+ },
+ LiteralNumber: ansi.StylePrimitive{
+ Color: stringPtr(dark.Teal().Hex),
+ },
+ LiteralString: ansi.StylePrimitive{
+ Color: stringPtr(dark.Yellow().Hex),
+ },
+ LiteralStringEscape: ansi.StylePrimitive{
+ Color: stringPtr(dark.Pink().Hex),
+ },
+ GenericDeleted: ansi.StylePrimitive{
+ Color: stringPtr(dark.Red().Hex),
+ },
+ GenericEmph: ansi.StylePrimitive{
+ Color: stringPtr(dark.Yellow().Hex),
+ Italic: boolPtr(true),
+ },
+ GenericInserted: ansi.StylePrimitive{
+ Color: stringPtr(dark.Green().Hex),
+ },
+ GenericStrong: ansi.StylePrimitive{
+ Color: stringPtr(dark.Peach().Hex),
+ Bold: boolPtr(true),
+ },
+ GenericSubheading: ansi.StylePrimitive{
+ Color: stringPtr(dark.Mauve().Hex),
+ },
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "\n",
+ BlockSuffix: "\n",
+ },
+ },
+ CenterSeparator: stringPtr("┼"),
+ ColumnSeparator: stringPtr("│"),
+ RowSeparator: stringPtr("─"),
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BlockPrefix: "\n ❯ ",
+ Color: stringPtr(dark.Sapphire().Hex),
+ },
+}
+
+var catppuccinLight = ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "\n",
+ BlockSuffix: "\n",
+ Color: stringPtr(light.Text().Hex),
+ },
+ Margin: uintPtr(defaultMargin),
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(light.Yellow().Hex),
+ Italic: boolPtr(true),
+ Prefix: "┃ ",
+ },
+ Indent: uintPtr(1),
+ Margin: uintPtr(defaultMargin),
+ },
+ List: ansi.StyleList{
+ LevelIndent: defaultMargin,
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(light.Text().Hex),
+ },
+ },
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockSuffix: "\n",
+ Color: stringPtr(light.Mauve().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "# ",
+ Color: stringPtr(light.Lavender().Hex),
+ Bold: boolPtr(true),
+ BlockPrefix: "\n",
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "## ",
+ Color: stringPtr(light.Mauve().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "### ",
+ Color: stringPtr(light.Pink().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "#### ",
+ Color: stringPtr(light.Flamingo().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "##### ",
+ Color: stringPtr(light.Rosewater().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "###### ",
+ Color: stringPtr(light.Rosewater().Hex),
+ Bold: boolPtr(true),
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ CrossedOut: boolPtr(true),
+ Color: stringPtr(light.Overlay1().Hex),
+ },
+ Emph: ansi.StylePrimitive{
+ Color: stringPtr(light.Yellow().Hex),
+ Italic: boolPtr(true),
+ },
+ Strong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ Color: stringPtr(light.Peach().Hex),
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ Color: stringPtr(light.Overlay0().Hex),
+ Format: "\n─────────────────────────────────────────\n",
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ Color: stringPtr(light.Blue().Hex),
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ Color: stringPtr(light.Sky().Hex),
+ },
+ Task: ansi.StyleTask{
+ StylePrimitive: ansi.StylePrimitive{},
+ Ticked: "[✓] ",
+ Unticked: "[ ] ",
+ },
+ Link: ansi.StylePrimitive{
+ Color: stringPtr(light.Sky().Hex),
+ Underline: boolPtr(true),
+ },
+ LinkText: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ Bold: boolPtr(true),
+ },
+ Image: ansi.StylePrimitive{
+ Color: stringPtr(light.Sapphire().Hex),
+ Underline: boolPtr(true),
+ Format: "🖼 {{.text}}",
+ },
+ ImageText: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ Format: "{{.text}}",
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(light.Green().Hex),
+ Prefix: " ",
+ Suffix: " ",
+ },
+ },
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Color: stringPtr(light.Text().Hex),
+ },
+
+ Margin: uintPtr(defaultMargin),
+ },
+ Chroma: &ansi.Chroma{
+ Text: ansi.StylePrimitive{
+ Color: stringPtr(light.Text().Hex),
+ },
+ Error: ansi.StylePrimitive{
+ Color: stringPtr(light.Text().Hex),
+ },
+ Comment: ansi.StylePrimitive{
+ Color: stringPtr(light.Overlay1().Hex),
+ },
+ CommentPreproc: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ Keyword: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ KeywordReserved: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ KeywordNamespace: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ KeywordType: ansi.StylePrimitive{
+ Color: stringPtr(light.Sky().Hex),
+ },
+ Operator: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ Punctuation: ansi.StylePrimitive{
+ Color: stringPtr(light.Text().Hex),
+ },
+ Name: ansi.StylePrimitive{
+ Color: stringPtr(light.Sky().Hex),
+ },
+ NameBuiltin: ansi.StylePrimitive{
+ Color: stringPtr(light.Sky().Hex),
+ },
+ NameTag: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ NameAttribute: ansi.StylePrimitive{
+ Color: stringPtr(light.Green().Hex),
+ },
+ NameClass: ansi.StylePrimitive{
+ Color: stringPtr(light.Sky().Hex),
+ },
+ NameConstant: ansi.StylePrimitive{
+ Color: stringPtr(light.Mauve().Hex),
+ },
+ NameDecorator: ansi.StylePrimitive{
+ Color: stringPtr(light.Green().Hex),
+ },
+ NameFunction: ansi.StylePrimitive{
+ Color: stringPtr(light.Green().Hex),
+ },
+ LiteralNumber: ansi.StylePrimitive{
+ Color: stringPtr(light.Teal().Hex),
+ },
+ LiteralString: ansi.StylePrimitive{
+ Color: stringPtr(light.Yellow().Hex),
+ },
+ LiteralStringEscape: ansi.StylePrimitive{
+ Color: stringPtr(light.Pink().Hex),
+ },
+ GenericDeleted: ansi.StylePrimitive{
+ Color: stringPtr(light.Red().Hex),
+ },
+ GenericEmph: ansi.StylePrimitive{
+ Color: stringPtr(light.Yellow().Hex),
+ Italic: boolPtr(true),
+ },
+ GenericInserted: ansi.StylePrimitive{
+ Color: stringPtr(light.Green().Hex),
+ },
+ GenericStrong: ansi.StylePrimitive{
+ Color: stringPtr(light.Peach().Hex),
+ Bold: boolPtr(true),
+ },
+ GenericSubheading: ansi.StylePrimitive{
+ Color: stringPtr(light.Mauve().Hex),
+ },
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "\n",
+ BlockSuffix: "\n",
+ },
+ },
+ CenterSeparator: stringPtr("┼"),
+ ColumnSeparator: stringPtr("│"),
+ RowSeparator: stringPtr("─"),
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BlockPrefix: "\n ❯ ",
+ Color: stringPtr(light.Sapphire().Hex),
+ },
+}
diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go
new file mode 100644
index 000000000..a52a8a2eb
--- /dev/null
+++ b/internal/tui/styles/styles.go
@@ -0,0 +1,121 @@
+package styles
+
+import (
+ catppuccin "github.com/catppuccin/go"
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ light = catppuccin.Latte
+ dark = catppuccin.Mocha
+)
+
+var (
+ Regular = lipgloss.NewStyle()
+ Bold = Regular.Bold(true)
+ Padded = Regular.Padding(0, 1)
+
+ Border = Regular.Border(lipgloss.NormalBorder())
+ ThickBorder = Regular.Border(lipgloss.ThickBorder())
+ DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
+ // Colors
+
+ Surface0 = lipgloss.AdaptiveColor{
+ Dark: dark.Surface0().Hex,
+ Light: light.Surface0().Hex,
+ }
+
+ Overlay0 = lipgloss.AdaptiveColor{
+ Dark: dark.Overlay0().Hex,
+ Light: light.Overlay0().Hex,
+ }
+
+ Ovelay1 = lipgloss.AdaptiveColor{
+ Dark: dark.Overlay1().Hex,
+ Light: light.Overlay1().Hex,
+ }
+
+ Text = lipgloss.AdaptiveColor{
+ Dark: dark.Text().Hex,
+ Light: light.Text().Hex,
+ }
+
+ SubText0 = lipgloss.AdaptiveColor{
+ Dark: dark.Subtext0().Hex,
+ Light: light.Subtext0().Hex,
+ }
+
+ SubText1 = lipgloss.AdaptiveColor{
+ Dark: dark.Subtext1().Hex,
+ Light: light.Subtext1().Hex,
+ }
+
+ LightGrey = lipgloss.AdaptiveColor{
+ Dark: dark.Surface0().Hex,
+ Light: light.Surface0().Hex,
+ }
+ Grey = lipgloss.AdaptiveColor{
+ Dark: dark.Surface1().Hex,
+ Light: light.Surface1().Hex,
+ }
+
+ DarkGrey = lipgloss.AdaptiveColor{
+ Dark: dark.Surface2().Hex,
+ Light: light.Surface2().Hex,
+ }
+
+ Base = lipgloss.AdaptiveColor{
+ Dark: dark.Base().Hex,
+ Light: light.Base().Hex,
+ }
+
+ Crust = lipgloss.AdaptiveColor{
+ Dark: dark.Crust().Hex,
+ Light: light.Crust().Hex,
+ }
+
+ Blue = lipgloss.AdaptiveColor{
+ Dark: dark.Blue().Hex,
+ Light: light.Blue().Hex,
+ }
+
+ Red = lipgloss.AdaptiveColor{
+ Dark: dark.Red().Hex,
+ Light: light.Red().Hex,
+ }
+
+ Green = lipgloss.AdaptiveColor{
+ Dark: dark.Green().Hex,
+ Light: light.Green().Hex,
+ }
+
+ Mauve = lipgloss.AdaptiveColor{
+ Dark: dark.Mauve().Hex,
+ Light: light.Mauve().Hex,
+ }
+
+ Teal = lipgloss.AdaptiveColor{
+ Dark: dark.Teal().Hex,
+ Light: light.Teal().Hex,
+ }
+
+ Rosewater = lipgloss.AdaptiveColor{
+ Dark: dark.Rosewater().Hex,
+ Light: light.Rosewater().Hex,
+ }
+
+ Flamingo = lipgloss.AdaptiveColor{
+ Dark: dark.Flamingo().Hex,
+ Light: light.Flamingo().Hex,
+ }
+
+ Lavender = lipgloss.AdaptiveColor{
+ Dark: dark.Lavender().Hex,
+ Light: light.Lavender().Hex,
+ }
+
+ Peach = lipgloss.AdaptiveColor{
+ Dark: dark.Peach().Hex,
+ Light: light.Peach().Hex,
+ }
+)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 000000000..5045e5952
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,99 @@
+package tui
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
+ "github.com/kujtimiihoxha/termai/internal/tui/page"
+)
+
+type keyMap struct {
+ Logs key.Binding
+ Back key.Binding
+ Quit key.Binding
+}
+
+var keys = keyMap{
+ Logs: key.NewBinding(
+ key.WithKeys("L"),
+ key.WithHelp("L", "logs"),
+ ),
+ Back: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "back"),
+ ),
+ Quit: key.NewBinding(
+ key.WithKeys("ctrl+c", "q"),
+ key.WithHelp("ctrl+c/q", "quit"),
+ ),
+}
+
+type appModel struct {
+ width, height int
+ currentPage page.PageID
+ previousPage page.PageID
+ pages map[page.PageID]tea.Model
+ loadedPages map[page.PageID]bool
+}
+
+func (a appModel) Init() tea.Cmd {
+ cmd := a.pages[a.currentPage].Init()
+ a.loadedPages[a.currentPage] = true
+ return cmd
+}
+
+func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ a.width, a.height = msg.Width, msg.Height
+ case tea.KeyMsg:
+ if key.Matches(msg, keys.Quit) {
+ return a, tea.Quit
+ }
+ if key.Matches(msg, keys.Back) {
+ if a.previousPage != "" {
+ return a, a.moveToPage(a.previousPage)
+ }
+ return a, nil
+ }
+ if key.Matches(msg, keys.Logs) {
+ return a, a.moveToPage(page.LogsPage)
+ }
+ }
+ p, cmd := a.pages[a.currentPage].Update(msg)
+ if p != nil {
+ a.pages[a.currentPage] = p
+ }
+ return a, cmd
+}
+
+func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
+ var cmd tea.Cmd
+ if _, ok := a.loadedPages[pageID]; !ok {
+ cmd = a.pages[pageID].Init()
+ a.loadedPages[pageID] = true
+ }
+ a.previousPage = a.currentPage
+ a.currentPage = pageID
+ if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
+ sizable.SetSize(a.width, a.height)
+ }
+
+ return cmd
+}
+
+func (a appModel) View() string {
+ return a.pages[a.currentPage].View()
+}
+
+func New() tea.Model {
+ return &appModel{
+ currentPage: page.ReplPage,
+ loadedPages: make(map[page.PageID]bool),
+ pages: map[page.PageID]tea.Model{
+ page.LogsPage: page.NewLogsPage(),
+ page.InitPage: page.NewInitPage(),
+ page.ReplPage: page.NewReplPage(),
+ },
+ }
+}