initial

Kujtim Hoxha created

Change summary

.gitignore                               |  44 ++
cmd/termai/main.go                       |  59 +++
go.mod                                   |  43 ++
go.sum                                   |  84 ++++
internal/logging/default.go              |  12 
internal/logging/logger.go               | 100 +++++
internal/logging/logging.go              |  17 
internal/logging/message.go              |  19 
internal/logging/writer.go               |  49 ++
internal/pubsub/broker.go                | 101 +++++
internal/pubsub/events.go                |  22 +
internal/tui/components/core/status.go   |   0 
internal/tui/components/logs/table.go    | 131 ++++++
internal/tui/components/repl/editor.go   |  21 +
internal/tui/components/repl/messages.go |  21 +
internal/tui/components/repl/threads.go  |  21 +
internal/tui/layout/bento.go             | 361 ++++++++++++++++++
internal/tui/layout/border.go            |  99 +++++
internal/tui/layout/layout.go            |  39 ++
internal/tui/layout/single.go            | 172 ++++++++
internal/tui/page/init.go                |  37 +
internal/tui/page/logs.go                |  25 +
internal/tui/page/page.go                |   3 
internal/tui/page/repl.go                |  19 
internal/tui/styles/icons.go             |  12 
internal/tui/styles/markdown.go          | 498 ++++++++++++++++++++++++++
internal/tui/styles/styles.go            | 121 ++++++
internal/tui/tui.go                      |  99 +++++
28 files changed, 2,229 insertions(+)

Detailed changes

.gitignore šŸ”—

@@ -0,0 +1,44 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+
+# IDE specific files
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS specific files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+debug.log
+
+# Binary output directory
+/bin/
+/dist/
+
+# Local environment variables
+.env
+.env.local
+
+.termai

cmd/termai/main.go šŸ”—

@@ -0,0 +1,59 @@
+package main
+
+import (
+	"context"
+	"sync"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/kujtimiihoxha/termai/internal/logging"
+	"github.com/kujtimiihoxha/termai/internal/tui"
+)
+
+var log = logging.Get()
+
+func main() {
+	log.Info("Starting termai...")
+	ctx := context.Background()
+
+	app := tea.NewProgram(
+		tui.New(),
+		tea.WithAltScreen(),
+	)
+	log.Info("Setting up subscriptions...")
+	ch, unsub := setupSubscriptions(ctx)
+	defer unsub()
+
+	go func() {
+		for msg := range ch {
+			app.Send(msg)
+		}
+	}()
+	if _, err := app.Run(); err != nil {
+		panic(err)
+	}
+}
+
+func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
+	ch := make(chan tea.Msg)
+	wg := sync.WaitGroup{}
+	ctx, cancel := context.WithCancel(ctx)
+
+	{
+		sub := log.Subscribe(ctx)
+		wg.Add(1)
+		go func() {
+			for ev := range sub {
+				ch <- ev
+			}
+			wg.Done()
+		}()
+	}
+	// cleanup function to be invoked when program is terminated.
+	return ch, func() {
+		cancel()
+		// Wait for relays to finish before closing channel, to avoid sends
+		// to a closed channel, which would result in a panic.
+		wg.Wait()
+		close(ch)
+	}
+}

go.mod šŸ”—

@@ -0,0 +1,43 @@
+module github.com/kujtimiihoxha/termai
+
+go 1.23.5
+
+require (
+	github.com/catppuccin/go v0.3.0
+	github.com/charmbracelet/bubbles v0.20.0
+	github.com/charmbracelet/bubbletea v1.3.4
+	github.com/charmbracelet/glamour v0.9.1
+	github.com/charmbracelet/lipgloss v1.1.0
+	github.com/go-logfmt/logfmt v0.6.0
+	golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
+)
+
+require (
+	github.com/alecthomas/chroma/v2 v2.15.0 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.8.0 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
+	github.com/dlclark/regexp2 v1.11.4 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/gorilla/css v1.0.1 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/reflow v0.3.0 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yuin/goldmark v1.7.8 // indirect
+	github.com/yuin/goldmark-emoji v1.0.5 // indirect
+	golang.org/x/net v0.33.0 // indirect
+	golang.org/x/sync v0.12.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.23.0 // indirect
+)

go.sum šŸ”—

@@ -0,0 +1,84 @@
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
+github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
+github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
+github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
+github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
+github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
+github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
+github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
+github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=

internal/logging/default.go šŸ”—

@@ -0,0 +1,12 @@
+package logging
+
+var defaultLogger Interface
+
+func Get() Interface {
+	if defaultLogger == nil {
+		defaultLogger = NewLogger(Options{
+			Level: "info",
+		})
+	}
+	return defaultLogger
+}

internal/logging/logger.go šŸ”—

@@ -0,0 +1,100 @@
+package logging
+
+import (
+	"context"
+	"io"
+	"log/slog"
+	"slices"
+
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
+	"golang.org/x/exp/maps"
+)
+
+const DefaultLevel = "info"
+
+var levels = map[string]slog.Level{
+	"debug":      slog.LevelDebug,
+	DefaultLevel: slog.LevelInfo,
+	"warn":       slog.LevelWarn,
+	"error":      slog.LevelError,
+}
+
+func ValidLevels() []string {
+	keys := maps.Keys(levels)
+	slices.SortFunc(keys, func(a, b string) int {
+		if a == DefaultLevel {
+			return -1
+		}
+		if b == DefaultLevel {
+			return 1
+		}
+		if a < b {
+			return -1
+		}
+		return 1
+	})
+	return keys
+}
+
+func NewLogger(opts Options) *Logger {
+	logger := &Logger{}
+	broker := pubsub.NewBroker[Message]()
+	writer := &writer{
+		messages: []Message{},
+		Broker:   broker,
+	}
+
+	handler := slog.NewTextHandler(
+		io.MultiWriter(append(opts.AdditionalWriters, writer)...),
+		&slog.HandlerOptions{
+			Level: slog.Level(levels[opts.Level]),
+		},
+	)
+	logger.logger = slog.New(handler)
+	logger.writer = writer
+
+	return logger
+}
+
+type Options struct {
+	Level             string
+	AdditionalWriters []io.Writer
+}
+
+type Logger struct {
+	logger *slog.Logger
+	writer *writer
+}
+
+func (l *Logger) Debug(msg string, args ...any) {
+	l.logger.Debug(msg, args...)
+}
+
+func (l *Logger) Info(msg string, args ...any) {
+	l.logger.Info(msg, args...)
+}
+
+func (l *Logger) Warn(msg string, args ...any) {
+	l.logger.Warn(msg, args...)
+}
+
+func (l *Logger) Error(msg string, args ...any) {
+	l.logger.Error(msg, args...)
+}
+
+func (l *Logger) List() []Message {
+	return l.writer.messages
+}
+
+func (l *Logger) Get(id string) (Message, error) {
+	for _, msg := range l.writer.messages {
+		if msg.ID == id {
+			return msg, nil
+		}
+	}
+	return Message{}, io.EOF
+}
+
+func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
+	return l.writer.Subscribe(ctx)
+}

internal/logging/logging.go šŸ”—

@@ -0,0 +1,17 @@
+package logging
+
+import (
+	"context"
+
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
+)
+
+type Interface interface {
+	Debug(msg string, args ...any)
+	Info(msg string, args ...any)
+	Warn(msg string, args ...any)
+	Error(msg string, args ...any)
+	Subscribe(ctx context.Context) <-chan pubsub.Event[Message]
+
+	List() []Message
+}

internal/logging/message.go šŸ”—

@@ -0,0 +1,19 @@
+package logging
+
+import (
+	"time"
+)
+
+// Message is the event payload for a log message
+type Message struct {
+	ID         string
+	Time       time.Time
+	Level      string
+	Message    string `json:"msg"`
+	Attributes []Attr
+}
+
+type Attr struct {
+	Key   string
+	Value string
+}

internal/logging/writer.go šŸ”—

@@ -0,0 +1,49 @@
+package logging
+
+import (
+	"bytes"
+	"fmt"
+	"time"
+
+	"github.com/go-logfmt/logfmt"
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
+)
+
+type writer struct {
+	messages []Message
+	*pubsub.Broker[Message]
+}
+
+func (w *writer) Write(p []byte) (int, error) {
+	d := logfmt.NewDecoder(bytes.NewReader(p))
+	for d.ScanRecord() {
+		msg := Message{
+			ID: time.Now().Format(time.RFC3339Nano),
+		}
+		for d.ScanKeyval() {
+			switch string(d.Key()) {
+			case "time":
+				parsed, err := time.Parse(time.RFC3339, string(d.Value()))
+				if err != nil {
+					return 0, fmt.Errorf("parsing time: %w", err)
+				}
+				msg.Time = parsed
+			case "level":
+				msg.Level = string(d.Value())
+			case "msg":
+				msg.Message = string(d.Value())
+			default:
+				msg.Attributes = append(msg.Attributes, Attr{
+					Key:   string(d.Key()),
+					Value: string(d.Value()),
+				})
+			}
+		}
+		w.messages = append(w.messages, msg)
+		w.Publish(pubsub.CreatedEvent, msg)
+	}
+	if d.Err() != nil {
+		return 0, d.Err()
+	}
+	return len(p), nil
+}

internal/pubsub/broker.go šŸ”—

@@ -0,0 +1,101 @@
+package pubsub
+
+import (
+	"context"
+	"sync"
+)
+
+const bufferSize = 1024
+
+type Logger interface {
+	Debug(msg string, args ...any)
+	Info(msg string, args ...any)
+	Warn(msg string, args ...any)
+	Error(msg string, args ...any)
+}
+
+// Broker allows clients to publish events and subscribe to events
+type Broker[T any] struct {
+	subs map[chan Event[T]]struct{} // subscriptions
+	mu   sync.Mutex                 // sync access to map
+	done chan struct{}              // close when broker is shutting down
+}
+
+// NewBroker constructs a pub/sub broker.
+func NewBroker[T any]() *Broker[T] {
+	b := &Broker[T]{
+		subs: make(map[chan Event[T]]struct{}),
+		done: make(chan struct{}),
+	}
+	return b
+}
+
+// Shutdown the broker, terminating any subscriptions.
+func (b *Broker[T]) Shutdown() {
+	close(b.done)
+
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	// Remove each subscriber entry, so Publish() cannot send any further
+	// messages, and close each subscriber's channel, so the subscriber cannot
+	// consume any more messages.
+	for ch := range b.subs {
+		delete(b.subs, ch)
+		close(ch)
+	}
+}
+
+// Subscribe subscribes the caller to a stream of events. The returned channel
+// is closed when the broker is shutdown.
+func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	// Check if broker has shutdown and if so return closed channel
+	select {
+	case <-b.done:
+		ch := make(chan Event[T])
+		close(ch)
+		return ch
+	default:
+	}
+
+	// Subscribe
+	sub := make(chan Event[T], bufferSize)
+	b.subs[sub] = struct{}{}
+
+	// Unsubscribe when context is done.
+	go func() {
+		<-ctx.Done()
+
+		b.mu.Lock()
+		defer b.mu.Unlock()
+
+		// Check if broker has shutdown and if so do nothing
+		select {
+		case <-b.done:
+			return
+		default:
+		}
+
+		delete(b.subs, sub)
+		close(sub)
+	}()
+
+	return sub
+}
+
+// Publish an event to subscribers.
+func (b *Broker[T]) Publish(t EventType, payload T) {
+	b.mu.Lock()
+	defer b.mu.Unlock()
+
+	for sub := range b.subs {
+		select {
+		case sub <- Event[T]{Type: t, Payload: payload}:
+		case <-b.done:
+			return
+		}
+	}
+}

internal/pubsub/events.go šŸ”—

@@ -0,0 +1,22 @@
+package pubsub
+
+const (
+	CreatedEvent EventType = "created"
+	UpdatedEvent EventType = "updated"
+	DeletedEvent EventType = "deleted"
+)
+
+type (
+	// EventType identifies the type of event
+	EventType string
+
+	// Event represents an event in the lifecycle of a resource
+	Event[T any] struct {
+		Type    EventType
+		Payload T
+	}
+
+	Publisher[T any] interface {
+		Publish(EventType, T)
+	}
+)

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,
+	}
+}

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{}
+}

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{}
+}

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{}
+}

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
+	}
+}

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")
+}

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
+}

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
+	}
+}

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",
+			},
+		),
+	)
+}

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
+}

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(),
+		},
+	)
+}

internal/tui/styles/icons.go šŸ”—

@@ -0,0 +1,12 @@
+package styles
+
+const (
+	SessionsIcon string = "󰧑"
+	ChatIcon     string = "ó°­¹"
+
+	BotIcon  string = "󰚩"
+	ToolIcon string = ""
+	UserIcon string = ""
+
+	SleepIcon string = "󰒲"
+)

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),
+	},
+}

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,
+	}
+)

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(),
+		},
+	}
+}