This commit is contained in:
Kujtim Hoxha 2025-03-21 18:20:28 +01:00
commit 4b0ea68d7a
28 changed files with 2229 additions and 0 deletions

44
.gitignore vendored Normal file
View file

@ -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

59
cmd/termai/main.go Normal file
View file

@ -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)
}
}

43
go.mod Normal file
View file

@ -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
)

84
go.sum Normal file
View file

@ -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=

View file

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

100
internal/logging/logger.go Normal file
View file

@ -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)
}

View file

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

View file

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

View file

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

101
internal/pubsub/broker.go Normal file
View file

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

22
internal/pubsub/events.go Normal file
View file

@ -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)
}
)

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

37
internal/tui/page/init.go Normal file
View file

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

25
internal/tui/page/logs.go Normal file
View file

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

View file

@ -0,0 +1,3 @@
package page
type PageID string

19
internal/tui/page/repl.go Normal file
View file

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

View file

@ -0,0 +1,12 @@
package styles
const (
SessionsIcon string = "󰧑"
ChatIcon string = "󰭹"
BotIcon string = "󰚩"
ToolIcon string = ""
UserIcon string = ""
SleepIcon string = "󰒲"
)

View file

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

View file

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

99
internal/tui/tui.go Normal file
View file

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