Detailed changes
@@ -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
@@ -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)
+ }
+}
@@ -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
+)
@@ -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=
@@ -0,0 +1,12 @@
+package logging
+
+var defaultLogger Interface
+
+func Get() Interface {
+ if defaultLogger == nil {
+ defaultLogger = NewLogger(Options{
+ Level: "info",
+ })
+ }
+ return defaultLogger
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+ }
+ }
+}
@@ -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)
+ }
+)
@@ -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,
+ }
+}
@@ -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{}
+}
@@ -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{}
+}
@@ -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{}
+}
@@ -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
+ }
+}
@@ -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")
+}
@@ -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
+}
@@ -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
+ }
+}
@@ -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",
+ },
+ ),
+ )
+}
@@ -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
+}
@@ -0,0 +1,3 @@
+package page
+
+type PageID string
@@ -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(),
+ },
+ )
+}
@@ -0,0 +1,12 @@
+package styles
+
+const (
+ SessionsIcon string = "ó°§"
+ ChatIcon string = "ó°¹"
+
+ BotIcon string = "ó°©"
+ ToolIcon string = "ļ„"
+ UserIcon string = "ļ"
+
+ SleepIcon string = "ó°²"
+)
@@ -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),
+ },
+}
@@ -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,
+ }
+)
@@ -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(),
+ },
+ }
+}