on main help sign in
initial
commit4b0ea68d7af9a6031a7ffda7ad66e0cb83315750 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 21 Mar 2025, 18:20 (2 years ago)
28 files changed+2229
.gitignore+44 cmd/termai/main.go+59 go.mod+43 go.sum+84 internal/logging/default.go+12 internal/logging/logger.go+100 internal/logging/logging.go+17 internal/logging/message.go+19 internal/logging/writer.go+49 internal/pubsub/broker.go+101 internal/pubsub/events.go+22 internal/tui/components/core/status.go internal/tui/components/logs/table.go+131 internal/tui/components/repl/editor.go+21 internal/tui/components/repl/messages.go+21 internal/tui/components/repl/threads.go+21 internal/tui/layout/bento.go+361 internal/tui/layout/border.go+99 internal/tui/layout/layout.go+39 internal/tui/layout/single.go+172 internal/tui/page/init.go+37 internal/tui/page/logs.go+25 internal/tui/page/page.go+3 internal/tui/page/repl.go+19 internal/tui/styles/icons.go+12 internal/tui/styles/markdown.go+498 internal/tui/styles/styles.go+121 internal/tui/tui.go+99
.gitignorenew file+44
@@ -0,0 +1,44 @@
1+# Binaries for programs and plugins
2+*.exe
3+*.exe~
4+*.dll
5+*.so
6+*.dylib
7+
8+# Test binary, built with `go test -c`
9+*.test
10+
11+# Output of the go coverage tool, specifically when used with LiteIDE
12+*.out
13+
14+# Dependency directories (remove the comment below to include it)
15+# vendor/
16+
17+# Go workspace file
18+go.work
19+
20+# IDE specific files
21+.idea/
22+.vscode/
23+*.swp
24+*.swo
25+
26+# OS specific files
27+.DS_Store
28+.DS_Store?
29+._*
30+.Spotlight-V100
31+.Trashes
32+ehthumbs.db
33+Thumbs.db
34+debug.log
35+
36+# Binary output directory
37+/bin/
38+/dist/
39+
40+# Local environment variables
41+.env
42+.env.local
43+
44+.termai
cmd/termai/main.gonew file+59
@@ -0,0 +1,59 @@
1+package main
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/kujtimiihoxha/termai/internal/logging"
9+ "github.com/kujtimiihoxha/termai/internal/tui"
10+)
11+
12+var log = logging.Get()
13+
14+func main() {
15+ log.Info("Starting termai...")
16+ ctx := context.Background()
17+
18+ app := tea.NewProgram(
19+ tui.New(),
20+ tea.WithAltScreen(),
21+ )
22+ log.Info("Setting up subscriptions...")
23+ ch, unsub := setupSubscriptions(ctx)
24+ defer unsub()
25+
26+ go func() {
27+ for msg := range ch {
28+ app.Send(msg)
29+ }
30+ }()
31+ if _, err := app.Run(); err != nil {
32+ panic(err)
33+ }
34+}
35+
36+func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
37+ ch := make(chan tea.Msg)
38+ wg := sync.WaitGroup{}
39+ ctx, cancel := context.WithCancel(ctx)
40+
41+ {
42+ sub := log.Subscribe(ctx)
43+ wg.Add(1)
44+ go func() {
45+ for ev := range sub {
46+ ch <- ev
47+ }
48+ wg.Done()
49+ }()
50+ }
51+ // cleanup function to be invoked when program is terminated.
52+ return ch, func() {
53+ cancel()
54+ // Wait for relays to finish before closing channel, to avoid sends
55+ // to a closed channel, which would result in a panic.
56+ wg.Wait()
57+ close(ch)
58+ }
59+}
go.modnew file+43
@@ -0,0 +1,43 @@
1+module github.com/kujtimiihoxha/termai
2+
3+go 1.23.5
4+
5+require (
6+ github.com/catppuccin/go v0.3.0
7+ github.com/charmbracelet/bubbles v0.20.0
8+ github.com/charmbracelet/bubbletea v1.3.4
9+ github.com/charmbracelet/glamour v0.9.1
10+ github.com/charmbracelet/lipgloss v1.1.0
11+ github.com/go-logfmt/logfmt v0.6.0
12+ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
13+)
14+
15+require (
16+ github.com/alecthomas/chroma/v2 v2.15.0 // indirect
17+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
18+ github.com/aymerick/douceur v0.2.0 // indirect
19+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
20+ github.com/charmbracelet/x/ansi v0.8.0 // indirect
21+ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
22+ github.com/charmbracelet/x/term v0.2.1 // indirect
23+ github.com/dlclark/regexp2 v1.11.4 // indirect
24+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25+ github.com/gorilla/css v1.0.1 // indirect
26+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
27+ github.com/mattn/go-isatty v0.0.20 // indirect
28+ github.com/mattn/go-localereader v0.0.1 // indirect
29+ github.com/mattn/go-runewidth v0.0.16 // indirect
30+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
31+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
32+ github.com/muesli/cancelreader v0.2.2 // indirect
33+ github.com/muesli/reflow v0.3.0 // indirect
34+ github.com/muesli/termenv v0.16.0 // indirect
35+ github.com/rivo/uniseg v0.4.7 // indirect
36+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
37+ github.com/yuin/goldmark v1.7.8 // indirect
38+ github.com/yuin/goldmark-emoji v1.0.5 // indirect
39+ golang.org/x/net v0.33.0 // indirect
40+ golang.org/x/sync v0.12.0 // indirect
41+ golang.org/x/sys v0.30.0 // indirect
42+ golang.org/x/text v0.23.0 // indirect
43+)
go.sumnew file+84
@@ -0,0 +1,84 @@
1+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
2+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
3+github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
4+github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
5+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
6+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
7+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9+github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
10+github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
11+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
12+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
13+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
14+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
15+github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
16+github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
17+github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
18+github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
19+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
20+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
21+github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
22+github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
23+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
24+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
25+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
26+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
27+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
28+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
29+github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
30+github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
31+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
32+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
33+github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
34+github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
35+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
36+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
37+github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
38+github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
39+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
40+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
41+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
42+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
43+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
44+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
45+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
46+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
47+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
48+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
49+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
50+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
51+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
52+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
53+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
54+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
55+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
56+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
57+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
58+github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
59+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
60+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
61+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
62+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
63+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
64+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
65+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
66+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
67+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
68+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
69+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
70+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
71+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
72+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
73+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
74+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
75+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
76+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
77+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
78+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
79+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
82+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
83+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
84+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
internal/logging/default.gonew file+12
@@ -0,0 +1,12 @@
1+package logging
2+
3+var defaultLogger Interface
4+
5+func Get() Interface {
6+ if defaultLogger == nil {
7+ defaultLogger = NewLogger(Options{
8+ Level: "info",
9+ })
10+ }
11+ return defaultLogger
12+}
internal/logging/logger.gonew file+100
@@ -0,0 +1,100 @@
1+package logging
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "slices"
8+
9+ "github.com/kujtimiihoxha/termai/internal/pubsub"
10+ "golang.org/x/exp/maps"
11+)
12+
13+const DefaultLevel = "info"
14+
15+var levels = map[string]slog.Level{
16+ "debug": slog.LevelDebug,
17+ DefaultLevel: slog.LevelInfo,
18+ "warn": slog.LevelWarn,
19+ "error": slog.LevelError,
20+}
21+
22+func ValidLevels() []string {
23+ keys := maps.Keys(levels)
24+ slices.SortFunc(keys, func(a, b string) int {
25+ if a == DefaultLevel {
26+ return -1
27+ }
28+ if b == DefaultLevel {
29+ return 1
30+ }
31+ if a < b {
32+ return -1
33+ }
34+ return 1
35+ })
36+ return keys
37+}
38+
39+func NewLogger(opts Options) *Logger {
40+ logger := &Logger{}
41+ broker := pubsub.NewBroker[Message]()
42+ writer := &writer{
43+ messages: []Message{},
44+ Broker: broker,
45+ }
46+
47+ handler := slog.NewTextHandler(
48+ io.MultiWriter(append(opts.AdditionalWriters, writer)...),
49+ &slog.HandlerOptions{
50+ Level: slog.Level(levels[opts.Level]),
51+ },
52+ )
53+ logger.logger = slog.New(handler)
54+ logger.writer = writer
55+
56+ return logger
57+}
58+
59+type Options struct {
60+ Level string
61+ AdditionalWriters []io.Writer
62+}
63+
64+type Logger struct {
65+ logger *slog.Logger
66+ writer *writer
67+}
68+
69+func (l *Logger) Debug(msg string, args ...any) {
70+ l.logger.Debug(msg, args...)
71+}
72+
73+func (l *Logger) Info(msg string, args ...any) {
74+ l.logger.Info(msg, args...)
75+}
76+
77+func (l *Logger) Warn(msg string, args ...any) {
78+ l.logger.Warn(msg, args...)
79+}
80+
81+func (l *Logger) Error(msg string, args ...any) {
82+ l.logger.Error(msg, args...)
83+}
84+
85+func (l *Logger) List() []Message {
86+ return l.writer.messages
87+}
88+
89+func (l *Logger) Get(id string) (Message, error) {
90+ for _, msg := range l.writer.messages {
91+ if msg.ID == id {
92+ return msg, nil
93+ }
94+ }
95+ return Message{}, io.EOF
96+}
97+
98+func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
99+ return l.writer.Subscribe(ctx)
100+}
internal/logging/logging.gonew file+17
@@ -0,0 +1,17 @@
1+package logging
2+
3+import (
4+ "context"
5+
6+ "github.com/kujtimiihoxha/termai/internal/pubsub"
7+)
8+
9+type Interface interface {
10+ Debug(msg string, args ...any)
11+ Info(msg string, args ...any)
12+ Warn(msg string, args ...any)
13+ Error(msg string, args ...any)
14+ Subscribe(ctx context.Context) <-chan pubsub.Event[Message]
15+
16+ List() []Message
17+}
internal/logging/message.gonew file+19
@@ -0,0 +1,19 @@
1+package logging
2+
3+import (
4+ "time"
5+)
6+
7+// Message is the event payload for a log message
8+type Message struct {
9+ ID string
10+ Time time.Time
11+ Level string
12+ Message string `json:"msg"`
13+ Attributes []Attr
14+}
15+
16+type Attr struct {
17+ Key string
18+ Value string
19+}
internal/logging/writer.gonew file+49
@@ -0,0 +1,49 @@
1+package logging
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "time"
7+
8+ "github.com/go-logfmt/logfmt"
9+ "github.com/kujtimiihoxha/termai/internal/pubsub"
10+)
11+
12+type writer struct {
13+ messages []Message
14+ *pubsub.Broker[Message]
15+}
16+
17+func (w *writer) Write(p []byte) (int, error) {
18+ d := logfmt.NewDecoder(bytes.NewReader(p))
19+ for d.ScanRecord() {
20+ msg := Message{
21+ ID: time.Now().Format(time.RFC3339Nano),
22+ }
23+ for d.ScanKeyval() {
24+ switch string(d.Key()) {
25+ case "time":
26+ parsed, err := time.Parse(time.RFC3339, string(d.Value()))
27+ if err != nil {
28+ return 0, fmt.Errorf("parsing time: %w", err)
29+ }
30+ msg.Time = parsed
31+ case "level":
32+ msg.Level = string(d.Value())
33+ case "msg":
34+ msg.Message = string(d.Value())
35+ default:
36+ msg.Attributes = append(msg.Attributes, Attr{
37+ Key: string(d.Key()),
38+ Value: string(d.Value()),
39+ })
40+ }
41+ }
42+ w.messages = append(w.messages, msg)
43+ w.Publish(pubsub.CreatedEvent, msg)
44+ }
45+ if d.Err() != nil {
46+ return 0, d.Err()
47+ }
48+ return len(p), nil
49+}
internal/pubsub/broker.gonew file+101
@@ -0,0 +1,101 @@
1+package pubsub
2+
3+import (
4+ "context"
5+ "sync"
6+)
7+
8+const bufferSize = 1024
9+
10+type Logger interface {
11+ Debug(msg string, args ...any)
12+ Info(msg string, args ...any)
13+ Warn(msg string, args ...any)
14+ Error(msg string, args ...any)
15+}
16+
17+// Broker allows clients to publish events and subscribe to events
18+type Broker[T any] struct {
19+ subs map[chan Event[T]]struct{} // subscriptions
20+ mu sync.Mutex // sync access to map
21+ done chan struct{} // close when broker is shutting down
22+}
23+
24+// NewBroker constructs a pub/sub broker.
25+func NewBroker[T any]() *Broker[T] {
26+ b := &Broker[T]{
27+ subs: make(map[chan Event[T]]struct{}),
28+ done: make(chan struct{}),
29+ }
30+ return b
31+}
32+
33+// Shutdown the broker, terminating any subscriptions.
34+func (b *Broker[T]) Shutdown() {
35+ close(b.done)
36+
37+ b.mu.Lock()
38+ defer b.mu.Unlock()
39+
40+ // Remove each subscriber entry, so Publish() cannot send any further
41+ // messages, and close each subscriber's channel, so the subscriber cannot
42+ // consume any more messages.
43+ for ch := range b.subs {
44+ delete(b.subs, ch)
45+ close(ch)
46+ }
47+}
48+
49+// Subscribe subscribes the caller to a stream of events. The returned channel
50+// is closed when the broker is shutdown.
51+func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
52+ b.mu.Lock()
53+ defer b.mu.Unlock()
54+
55+ // Check if broker has shutdown and if so return closed channel
56+ select {
57+ case <-b.done:
58+ ch := make(chan Event[T])
59+ close(ch)
60+ return ch
61+ default:
62+ }
63+
64+ // Subscribe
65+ sub := make(chan Event[T], bufferSize)
66+ b.subs[sub] = struct{}{}
67+
68+ // Unsubscribe when context is done.
69+ go func() {
70+ <-ctx.Done()
71+
72+ b.mu.Lock()
73+ defer b.mu.Unlock()
74+
75+ // Check if broker has shutdown and if so do nothing
76+ select {
77+ case <-b.done:
78+ return
79+ default:
80+ }
81+
82+ delete(b.subs, sub)
83+ close(sub)
84+ }()
85+
86+ return sub
87+}
88+
89+// Publish an event to subscribers.
90+func (b *Broker[T]) Publish(t EventType, payload T) {
91+ b.mu.Lock()
92+ defer b.mu.Unlock()
93+
94+ for sub := range b.subs {
95+ select {
96+ case sub <- Event[T]{Type: t, Payload: payload}:
97+ case <-b.done:
98+ return
99+ }
100+ }
101+}
internal/pubsub/events.gonew file+22
@@ -0,0 +1,22 @@
1+package pubsub
2+
3+const (
4+ CreatedEvent EventType = "created"
5+ UpdatedEvent EventType = "updated"
6+ DeletedEvent EventType = "deleted"
7+)
8+
9+type (
10+ // EventType identifies the type of event
11+ EventType string
12+
13+ // Event represents an event in the lifecycle of a resource
14+ Event[T any] struct {
15+ Type EventType
16+ Payload T
17+ }
18+
19+ Publisher[T any] interface {
20+ Publish(EventType, T)
21+ }
22+)
internal/tui/components/core/status.gonew file

No changes to the content.

internal/tui/components/logs/table.gonew file+131
@@ -0,0 +1,131 @@
1+package logs
2+
3+import (
4+ "encoding/json"
5+ "slices"
6+
7+ "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/table"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/kujtimiihoxha/termai/internal/logging"
11+ "github.com/kujtimiihoxha/termai/internal/pubsub"
12+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
13+)
14+
15+type TableComponent interface {
16+ tea.Model
17+ layout.Focusable
18+ layout.Sizeable
19+ layout.Bindings
20+}
21+
22+var logger = logging.Get()
23+
24+type tableCmp struct {
25+ table table.Model
26+}
27+
28+func (i *tableCmp) Init() tea.Cmd {
29+ i.setRows()
30+ return nil
31+}
32+
33+func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
34+ if i.table.Focused() {
35+ switch msg := msg.(type) {
36+ case pubsub.Event[logging.Message]:
37+ i.setRows()
38+ return i, nil
39+ case tea.KeyMsg:
40+ if msg.String() == "ctrl+s" {
41+ logger.Info("Saving logs...",
42+ "rows", len(i.table.Rows()),
43+ )
44+ }
45+ }
46+ t, cmd := i.table.Update(msg)
47+ i.table = t
48+ return i, cmd
49+ }
50+ return i, nil
51+}
52+
53+func (i *tableCmp) View() string {
54+ return i.table.View()
55+}
56+
57+func (i *tableCmp) Blur() tea.Cmd {
58+ i.table.Blur()
59+ return nil
60+}
61+
62+func (i *tableCmp) Focus() tea.Cmd {
63+ i.table.Focus()
64+ return nil
65+}
66+
67+func (i *tableCmp) IsFocused() bool {
68+ return i.table.Focused()
69+}
70+
71+func (i *tableCmp) GetSize() (int, int) {
72+ return i.table.Width(), i.table.Height()
73+}
74+
75+func (i *tableCmp) SetSize(width int, height int) {
76+ i.table.SetWidth(width)
77+ i.table.SetHeight(height)
78+ cloumns := i.table.Columns()
79+ for i, col := range cloumns {
80+ col.Width = (width / len(cloumns)) - 2
81+ cloumns[i] = col
82+ }
83+ i.table.SetColumns(cloumns)
84+}
85+
86+func (i *tableCmp) BindingKeys() []key.Binding {
87+ return layout.KeyMapToSlice(i.table.KeyMap)
88+}
89+
90+func (i *tableCmp) setRows() {
91+ rows := []table.Row{}
92+
93+ logs := logger.List()
94+ slices.SortFunc(logs, func(a, b logging.Message) int {
95+ if a.Time.Before(b.Time) {
96+ return 1
97+ }
98+ if a.Time.After(b.Time) {
99+ return -1
100+ }
101+ return 0
102+ })
103+
104+ for _, log := range logs {
105+ bm, _ := json.Marshal(log.Attributes)
106+
107+ row := table.Row{
108+ log.Time.Format("15:04:05"),
109+ log.Level,
110+ log.Message,
111+ string(bm),
112+ }
113+ rows = append(rows, row)
114+ }
115+ i.table.SetRows(rows)
116+}
117+
118+func NewLogsTable() TableComponent {
119+ columns := []table.Column{
120+ {Title: "Time", Width: 4},
121+ {Title: "Level", Width: 10},
122+ {Title: "Message", Width: 10},
123+ {Title: "Attributes", Width: 10},
124+ }
125+ tableModel := table.New(
126+ table.WithColumns(columns),
127+ )
128+ return &tableCmp{
129+ table: tableModel,
130+ }
131+}
internal/tui/components/repl/editor.gonew file+21
@@ -0,0 +1,21 @@
1+package repl
2+
3+import tea "github.com/charmbracelet/bubbletea"
4+
5+type editorCmp struct{}
6+
7+func (i *editorCmp) Init() tea.Cmd {
8+ return nil
9+}
10+
11+func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
12+ return i, nil
13+}
14+
15+func (i *editorCmp) View() string {
16+ return "Editor"
17+}
18+
19+func NewEditorCmp() tea.Model {
20+ return &editorCmp{}
21+}
internal/tui/components/repl/messages.gonew file+21
@@ -0,0 +1,21 @@
1+package repl
2+
3+import tea "github.com/charmbracelet/bubbletea"
4+
5+type messagesCmp struct{}
6+
7+func (i *messagesCmp) Init() tea.Cmd {
8+ return nil
9+}
10+
11+func (i *messagesCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
12+ return i, nil
13+}
14+
15+func (i *messagesCmp) View() string {
16+ return "Messages"
17+}
18+
19+func NewMessagesCmp() tea.Model {
20+ return &messagesCmp{}
21+}
internal/tui/components/repl/threads.gonew file+21
@@ -0,0 +1,21 @@
1+package repl
2+
3+import tea "github.com/charmbracelet/bubbletea"
4+
5+type threadsCmp struct{}
6+
7+func (i *threadsCmp) Init() tea.Cmd {
8+ return nil
9+}
10+
11+func (i *threadsCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
12+ return i, nil
13+}
14+
15+func (i *threadsCmp) View() string {
16+ return "Threads"
17+}
18+
19+func NewThreadsCmp() tea.Model {
20+ return &threadsCmp{}
21+}
internal/tui/layout/bento.gonew file+361
@@ -0,0 +1,361 @@
1+package layout
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+type paneID string
10+
11+const (
12+ BentoLeftPane paneID = "left"
13+ BentoRightTopPane paneID = "right-top"
14+ BentoRightBottomPane paneID = "right-bottom"
15+)
16+
17+type BentoPanes map[paneID]tea.Model
18+
19+const (
20+ defaultLeftWidthRatio = 0.2
21+ defaultRightTopHeightRatio = 0.85
22+
23+ minLeftWidth = 10
24+ minRightBottomHeight = 10
25+)
26+
27+type BentoLayout interface {
28+ tea.Model
29+ Sizeable
30+ Bindings
31+}
32+
33+type BentoKeyBindings struct {
34+ SwitchPane key.Binding
35+ SwitchPaneBack key.Binding
36+ HideCurrentPane key.Binding
37+ ShowAllPanes key.Binding
38+}
39+
40+var defaultBentoKeyBindings = BentoKeyBindings{
41+ SwitchPane: key.NewBinding(
42+ key.WithKeys("tab"),
43+ key.WithHelp("tab", "switch pane"),
44+ ),
45+ SwitchPaneBack: key.NewBinding(
46+ key.WithKeys("shift+tab"),
47+ key.WithHelp("shift+tab", "switch pane back"),
48+ ),
49+ HideCurrentPane: key.NewBinding(
50+ key.WithKeys("X"),
51+ key.WithHelp("X", "hide current pane"),
52+ ),
53+ ShowAllPanes: key.NewBinding(
54+ key.WithKeys("R"),
55+ key.WithHelp("R", "show all panes"),
56+ ),
57+}
58+
59+type bentoLayout struct {
60+ width int
61+ height int
62+
63+ leftWidthRatio float64
64+ rightTopHeightRatio float64
65+
66+ currentPane paneID
67+ panes map[paneID]SinglePaneLayout
68+ hiddenPanes map[paneID]bool
69+}
70+
71+func (b *bentoLayout) GetSize() (int, int) {
72+ return b.width, b.height
73+}
74+
75+func (b bentoLayout) Init() tea.Cmd {
76+ return nil
77+}
78+
79+func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
80+ switch msg := msg.(type) {
81+ case tea.WindowSizeMsg:
82+ b.SetSize(msg.Width, msg.Height)
83+ return b, nil
84+ case tea.KeyMsg:
85+ switch {
86+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
87+ return b, b.SwitchPane(false)
88+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
89+ return b, b.SwitchPane(true)
90+ case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
91+ return b, b.HidePane(b.currentPane)
92+ case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
93+ for id := range b.hiddenPanes {
94+ delete(b.hiddenPanes, id)
95+ }
96+ b.SetSize(b.width, b.height)
97+ return b, nil
98+ }
99+ }
100+
101+ if pane, ok := b.panes[b.currentPane]; ok {
102+ u, cmd := pane.Update(msg)
103+ b.panes[b.currentPane] = u.(SinglePaneLayout)
104+ return b, cmd
105+ }
106+ return b, nil
107+}
108+
109+func (b bentoLayout) View() string {
110+ if b.width <= 0 || b.height <= 0 {
111+ return ""
112+ }
113+
114+ for id, pane := range b.panes {
115+ if b.currentPane == id {
116+ pane.Focus()
117+ } else {
118+ pane.Blur()
119+ }
120+ }
121+
122+ leftVisible := false
123+ rightTopVisible := false
124+ rightBottomVisible := false
125+
126+ var leftPane, rightTopPane, rightBottomPane string
127+
128+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
129+ leftPane = pane.View()
130+ leftVisible = true
131+ }
132+
133+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
134+ rightTopPane = pane.View()
135+ rightTopVisible = true
136+ }
137+
138+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
139+ rightBottomPane = pane.View()
140+ rightBottomVisible = true
141+ }
142+
143+ if leftVisible {
144+ if rightTopVisible || rightBottomVisible {
145+ rightSection := ""
146+ if rightTopVisible && rightBottomVisible {
147+ rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
148+ } else if rightTopVisible {
149+ rightSection = rightTopPane
150+ } else {
151+ rightSection = rightBottomPane
152+ }
153+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
154+ lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
155+ )
156+ } else {
157+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
158+ }
159+ } else if rightTopVisible || rightBottomVisible {
160+ if rightTopVisible && rightBottomVisible {
161+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
162+ lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
163+ )
164+ } else if rightTopVisible {
165+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
166+ } else {
167+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
168+ }
169+ }
170+ return ""
171+}
172+
173+func (b *bentoLayout) SetSize(width int, height int) {
174+ if width < 0 || height < 0 {
175+ return
176+ }
177+ b.width = width
178+ b.height = height
179+
180+ // Check which panes are available
181+ leftExists := false
182+ rightTopExists := false
183+ rightBottomExists := false
184+
185+ if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
186+ leftExists = true
187+ }
188+ if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
189+ rightTopExists = true
190+ }
191+ if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
192+ rightBottomExists = true
193+ }
194+
195+ leftWidth := 0
196+ rightWidth := 0
197+ rightTopHeight := 0
198+ rightBottomHeight := 0
199+
200+ if leftExists && (rightTopExists || rightBottomExists) {
201+ leftWidth = int(float64(width) * b.leftWidthRatio)
202+ if leftWidth < minLeftWidth && width >= minLeftWidth {
203+ leftWidth = minLeftWidth
204+ }
205+ rightWidth = width - leftWidth
206+
207+ if rightTopExists && rightBottomExists {
208+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
209+ rightBottomHeight = height - rightTopHeight
210+
211+ // Ensure minimum height for bottom pane
212+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
213+ rightBottomHeight = minRightBottomHeight
214+ rightTopHeight = height - rightBottomHeight
215+ }
216+ } else if rightTopExists {
217+ rightTopHeight = height
218+ } else if rightBottomExists {
219+ rightBottomHeight = height
220+ }
221+ } else if leftExists {
222+ leftWidth = width
223+ } else if rightTopExists || rightBottomExists {
224+ rightWidth = width
225+
226+ if rightTopExists && rightBottomExists {
227+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
228+ rightBottomHeight = height - rightTopHeight
229+
230+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
231+ rightBottomHeight = minRightBottomHeight
232+ rightTopHeight = height - rightBottomHeight
233+ }
234+ } else if rightTopExists {
235+ rightTopHeight = height
236+ } else if rightBottomExists {
237+ rightBottomHeight = height
238+ }
239+ }
240+
241+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
242+ pane.SetSize(leftWidth, height)
243+ }
244+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
245+ pane.SetSize(rightWidth, rightTopHeight)
246+ }
247+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
248+ pane.SetSize(rightWidth, rightBottomHeight)
249+ }
250+}
251+
252+func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
253+ if len(b.panes)-len(b.hiddenPanes) == 1 {
254+ return nil
255+ }
256+ if _, ok := b.panes[pane]; ok {
257+ b.hiddenPanes[pane] = true
258+ }
259+ b.SetSize(b.width, b.height)
260+ return b.SwitchPane(false)
261+}
262+
263+func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
264+ if back {
265+ switch b.currentPane {
266+ case BentoLeftPane:
267+ b.currentPane = BentoRightBottomPane
268+ case BentoRightTopPane:
269+ b.currentPane = BentoLeftPane
270+ case BentoRightBottomPane:
271+ b.currentPane = BentoRightTopPane
272+ }
273+ } else {
274+ switch b.currentPane {
275+ case BentoLeftPane:
276+ b.currentPane = BentoRightTopPane
277+ case BentoRightTopPane:
278+ b.currentPane = BentoRightBottomPane
279+ case BentoRightBottomPane:
280+ b.currentPane = BentoLeftPane
281+ }
282+ }
283+
284+ var cmds []tea.Cmd
285+ for id, pane := range b.panes {
286+ if _, ok := b.hiddenPanes[id]; ok {
287+ continue
288+ }
289+ if id == b.currentPane {
290+ cmds = append(cmds, pane.Focus())
291+ } else {
292+ cmds = append(cmds, pane.Blur())
293+ }
294+ }
295+
296+ return tea.Batch(cmds...)
297+}
298+
299+func (s *bentoLayout) BindingKeys() []key.Binding {
300+ bindings := KeyMapToSlice(defaultBentoKeyBindings)
301+ if b, ok := s.panes[s.currentPane].(Bindings); ok {
302+ bindings = append(bindings, b.BindingKeys()...)
303+ }
304+ return bindings
305+}
306+
307+type BentoLayoutOption func(*bentoLayout)
308+
309+func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
310+ p := make(map[paneID]SinglePaneLayout, len(panes))
311+ for id, pane := range panes {
312+ // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
313+ if _, ok := pane.(SinglePaneLayout); !ok {
314+ p[id] = NewSinglePane(
315+ pane,
316+ WithSinglePaneFocusable(true),
317+ WithSinglePaneBordered(true),
318+ )
319+ } else {
320+ p[id] = pane.(SinglePaneLayout)
321+ }
322+ }
323+ if len(p) == 0 {
324+ panic("no panes provided for BentoLayout")
325+ }
326+ layout := &bentoLayout{
327+ panes: p,
328+ hiddenPanes: make(map[paneID]bool),
329+ currentPane: BentoLeftPane,
330+ leftWidthRatio: defaultLeftWidthRatio,
331+ rightTopHeightRatio: defaultRightTopHeightRatio,
332+ }
333+
334+ for _, opt := range opts {
335+ opt(layout)
336+ }
337+
338+ return layout
339+}
340+
341+func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
342+ return func(b *bentoLayout) {
343+ if ratio > 0 && ratio < 1 {
344+ b.leftWidthRatio = ratio
345+ }
346+ }
347+}
348+
349+func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
350+ return func(b *bentoLayout) {
351+ if ratio > 0 && ratio < 1 {
352+ b.rightTopHeightRatio = ratio
353+ }
354+ }
355+}
356+
357+func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
358+ return func(b *bentoLayout) {
359+ b.currentPane = pane
360+ }
361+}
internal/tui/layout/border.gonew file+99
@@ -0,0 +1,99 @@
1+package layout
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
9+)
10+
11+type BorderPosition int
12+
13+const (
14+ TopLeftBorder BorderPosition = iota
15+ TopMiddleBorder
16+ TopRightBorder
17+ BottomLeftBorder
18+ BottomMiddleBorder
19+ BottomRightBorder
20+)
21+
22+var (
23+ ActiveBorder = styles.Blue
24+ InactivePreviewBorder = styles.Grey
25+)
26+
27+func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
28+ if embeddedText == nil {
29+ embeddedText = make(map[BorderPosition]string)
30+ }
31+ var (
32+ thickness = map[bool]lipgloss.Border{
33+ true: lipgloss.Border(lipgloss.ThickBorder()),
34+ false: lipgloss.Border(lipgloss.NormalBorder()),
35+ }
36+ color = map[bool]lipgloss.TerminalColor{
37+ true: ActiveBorder,
38+ false: InactivePreviewBorder,
39+ }
40+ border = thickness[active]
41+ style = lipgloss.NewStyle().Foreground(color[active])
42+ width = lipgloss.Width(content)
43+ )
44+
45+ encloseInSquareBrackets := func(text string) string {
46+ if text != "" {
47+ return fmt.Sprintf("%s%s%s",
48+ style.Render(border.TopRight),
49+ text,
50+ style.Render(border.TopLeft),
51+ )
52+ }
53+ return text
54+ }
55+ buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
56+ leftText = encloseInSquareBrackets(leftText)
57+ middleText = encloseInSquareBrackets(middleText)
58+ rightText = encloseInSquareBrackets(rightText)
59+ // Calculate length of border between embedded texts
60+ remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
61+ leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
62+ rightBorderLen := max(0, remaining-leftBorderLen)
63+ // Then construct border string
64+ s := leftText +
65+ style.Render(strings.Repeat(inbetween, leftBorderLen)) +
66+ middleText +
67+ style.Render(strings.Repeat(inbetween, rightBorderLen)) +
68+ rightText
69+ // Make it fit in the space available between the two corners.
70+ s = lipgloss.NewStyle().
71+ Inline(true).
72+ MaxWidth(width).
73+ Render(s)
74+ // Add the corners
75+ return style.Render(leftCorner) + s + style.Render(rightCorner)
76+ }
77+ // Stack top border, content and horizontal borders, and bottom border.
78+ return strings.Join([]string{
79+ buildHorizontalBorder(
80+ embeddedText[TopLeftBorder],
81+ embeddedText[TopMiddleBorder],
82+ embeddedText[TopRightBorder],
83+ border.TopLeft,
84+ border.Top,
85+ border.TopRight,
86+ ),
87+ lipgloss.NewStyle().
88+ BorderForeground(color[active]).
89+ Border(border, false, true, false, true).Render(content),
90+ buildHorizontalBorder(
91+ embeddedText[BottomLeftBorder],
92+ embeddedText[BottomMiddleBorder],
93+ embeddedText[BottomRightBorder],
94+ border.BottomLeft,
95+ border.Bottom,
96+ border.BottomRight,
97+ ),
98+ }, "\n")
99+}
internal/tui/layout/layout.gonew file+39
@@ -0,0 +1,39 @@
1+package layout
2+
3+import (
4+ "reflect"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+)
9+
10+type Focusable interface {
11+ Focus() tea.Cmd
12+ Blur() tea.Cmd
13+ IsFocused() bool
14+}
15+
16+type Bordered interface {
17+ BorderText() map[BorderPosition]string
18+}
19+
20+type Sizeable interface {
21+ SetSize(width, height int)
22+ GetSize() (int, int)
23+}
24+
25+type Bindings interface {
26+ BindingKeys() []key.Binding
27+}
28+
29+func KeyMapToSlice(t any) (bindings []key.Binding) {
30+ typ := reflect.TypeOf(t)
31+ if typ.Kind() != reflect.Struct {
32+ return nil
33+ }
34+ for i := range typ.NumField() {
35+ v := reflect.ValueOf(t).Field(i)
36+ bindings = append(bindings, v.Interface().(key.Binding))
37+ }
38+ return
39+}
internal/tui/layout/single.gonew file+172
@@ -0,0 +1,172 @@
1+package layout
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+type SinglePaneLayout interface {
10+ tea.Model
11+ Focusable
12+ Sizeable
13+ Bindings
14+}
15+
16+type singlePaneLayout struct {
17+ width int
18+ height int
19+
20+ focusable bool
21+ focused bool
22+
23+ bordered bool
24+ borderText map[BorderPosition]string
25+
26+ content tea.Model
27+
28+ padding []int
29+}
30+
31+type SinglePaneOption func(*singlePaneLayout)
32+
33+func (s singlePaneLayout) Init() tea.Cmd {
34+ return s.content.Init()
35+}
36+
37+func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38+ switch msg := msg.(type) {
39+ case tea.WindowSizeMsg:
40+ s.SetSize(msg.Width, msg.Height)
41+ return s, nil
42+ }
43+ u, cmd := s.content.Update(msg)
44+ s.content = u
45+ return s, cmd
46+}
47+
48+func (s singlePaneLayout) View() string {
49+ style := lipgloss.NewStyle().Width(s.width).Height(s.height)
50+ if s.bordered {
51+ style = style.Width(s.width).Height(s.height)
52+ }
53+ if s.padding != nil {
54+ style = style.Padding(s.padding...)
55+ }
56+ content := style.Render(s.content.View())
57+ if s.bordered {
58+ if s.borderText == nil {
59+ s.borderText = map[BorderPosition]string{}
60+ }
61+ if bordered, ok := s.content.(Bordered); ok {
62+ s.borderText = bordered.BorderText()
63+ }
64+ return Borderize(content, s.focused, s.borderText)
65+ }
66+ return content
67+}
68+
69+func (s *singlePaneLayout) Blur() tea.Cmd {
70+ if s.focusable {
71+ s.focused = false
72+ }
73+ if blurable, ok := s.content.(Focusable); ok {
74+ return blurable.Blur()
75+ }
76+ return nil
77+}
78+
79+func (s *singlePaneLayout) Focus() tea.Cmd {
80+ if s.focusable {
81+ s.focused = true
82+ }
83+ if focusable, ok := s.content.(Focusable); ok {
84+ return focusable.Focus()
85+ }
86+ return nil
87+}
88+
89+func (s *singlePaneLayout) SetSize(width, height int) {
90+ s.width = width
91+ s.height = height
92+ if s.bordered {
93+ s.width -= 2
94+ s.height -= 2
95+ }
96+ if s.padding != nil {
97+ if len(s.padding) == 1 {
98+ s.width -= s.padding[0] * 2
99+ s.height -= s.padding[0] * 2
100+ } else if len(s.padding) == 2 {
101+ s.width -= s.padding[0] * 2
102+ s.height -= s.padding[1] * 2
103+ } else if len(s.padding) == 3 {
104+ s.width -= s.padding[0] * 2
105+ s.height -= s.padding[1] + s.padding[2]
106+ } else if len(s.padding) == 4 {
107+ s.width -= s.padding[0] + s.padding[2]
108+ s.height -= s.padding[1] + s.padding[3]
109+ }
110+ }
111+ if s.content != nil {
112+ if c, ok := s.content.(Sizeable); ok {
113+ c.SetSize(s.width, s.height)
114+ }
115+ }
116+}
117+
118+func (s *singlePaneLayout) IsFocused() bool {
119+ return s.focused
120+}
121+
122+func (s *singlePaneLayout) GetSize() (int, int) {
123+ return s.width, s.height
124+}
125+
126+func (s *singlePaneLayout) BindingKeys() []key.Binding {
127+ if b, ok := s.content.(Bindings); ok {
128+ return b.BindingKeys()
129+ }
130+ return []key.Binding{}
131+}
132+
133+func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
134+ layout := &singlePaneLayout{
135+ content: content,
136+ }
137+ for _, opt := range opts {
138+ opt(layout)
139+ }
140+ return layout
141+}
142+
143+func WithSignlePaneSize(width, height int) SinglePaneOption {
144+ return func(opts *singlePaneLayout) {
145+ opts.width = width
146+ opts.height = height
147+ }
148+}
149+
150+func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
151+ return func(opts *singlePaneLayout) {
152+ opts.focusable = focusable
153+ }
154+}
155+
156+func WithSinglePaneBordered(bordered bool) SinglePaneOption {
157+ return func(opts *singlePaneLayout) {
158+ opts.bordered = bordered
159+ }
160+}
161+
162+func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
163+ return func(opts *singlePaneLayout) {
164+ opts.borderText = borderText
165+ }
166+}
167+
168+func WithSinglePanePadding(padding ...int) SinglePaneOption {
169+ return func(opts *singlePaneLayout) {
170+ opts.padding = padding
171+ }
172+}
internal/tui/page/init.gonew file+37
@@ -0,0 +1,37 @@
1+package page
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea"
5+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
6+)
7+
8+var InitPage PageID = "init"
9+
10+type initPage struct {
11+ layout layout.SinglePaneLayout
12+}
13+
14+func (i initPage) Init() tea.Cmd {
15+ return nil
16+}
17+
18+func (i initPage) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
19+ return i, nil
20+}
21+
22+func (i initPage) View() string {
23+ return "Initializing..."
24+}
25+
26+func NewInitPage() tea.Model {
27+ return layout.NewSinglePane(
28+ &initPage{},
29+ layout.WithSinglePaneFocusable(true),
30+ layout.WithSinglePaneBordered(true),
31+ layout.WithSignlePaneBorderText(
32+ map[layout.BorderPosition]string{
33+ layout.TopMiddleBorder: "Welcome to termai",
34+ },
35+ ),
36+ )
37+}
internal/tui/page/logs.gonew file+25
@@ -0,0 +1,25 @@
1+package page
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea"
5+ "github.com/kujtimiihoxha/termai/internal/tui/components/logs"
6+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
7+)
8+
9+var LogsPage PageID = "logs"
10+
11+func NewLogsPage() tea.Model {
12+ p := layout.NewSinglePane(
13+ logs.NewLogsTable(),
14+ layout.WithSinglePaneFocusable(true),
15+ layout.WithSinglePaneBordered(true),
16+ layout.WithSignlePaneBorderText(
17+ map[layout.BorderPosition]string{
18+ layout.TopMiddleBorder: "Logs",
19+ },
20+ ),
21+ layout.WithSinglePanePadding(1),
22+ )
23+ p.Focus()
24+ return p
25+}
internal/tui/page/page.gonew file+3
@@ -0,0 +1,3 @@
1+package page
2+
3+type PageID string
internal/tui/page/repl.gonew file+19
@@ -0,0 +1,19 @@
1+package page
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea"
5+ "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
6+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
7+)
8+
9+var ReplPage PageID = "repl"
10+
11+func NewReplPage() tea.Model {
12+ return layout.NewBentoLayout(
13+ layout.BentoPanes{
14+ layout.BentoLeftPane: repl.NewThreadsCmp(),
15+ layout.BentoRightTopPane: repl.NewMessagesCmp(),
16+ layout.BentoRightBottomPane: repl.NewEditorCmp(),
17+ },
18+ )
19+}
internal/tui/styles/icons.gonew file+12
@@ -0,0 +1,12 @@
1+package styles
2+
3+const (
4+ SessionsIcon string = "󰧑"
5+ ChatIcon string = "󰭹"
6+
7+ BotIcon string = "󰚩"
8+ ToolIcon string = ""
9+ UserIcon string = ""
10+
11+ SleepIcon string = "󰒲"
12+)
internal/tui/styles/markdown.gonew file+498
@@ -0,0 +1,498 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/glamour/ansi"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+const defaultMargin = 2
9+
10+// Helper functions for style pointers
11+func boolPtr(b bool) *bool { return &b }
12+func stringPtr(s string) *string { return &s }
13+func uintPtr(u uint) *uint { return &u }
14+
15+// CatppuccinMarkdownStyle is the Catppuccin Mocha style for Glamour markdown rendering.
16+func CatppuccinMarkdownStyle() ansi.StyleConfig {
17+ isDark := lipgloss.HasDarkBackground()
18+ if isDark {
19+ return catppuccinDark
20+ }
21+ return catppuccinLight
22+}
23+
24+var catppuccinDark = ansi.StyleConfig{
25+ Document: ansi.StyleBlock{
26+ StylePrimitive: ansi.StylePrimitive{
27+ BlockPrefix: "\n",
28+ BlockSuffix: "\n",
29+ Color: stringPtr(dark.Text().Hex),
30+ },
31+ Margin: uintPtr(defaultMargin),
32+ },
33+ BlockQuote: ansi.StyleBlock{
34+ StylePrimitive: ansi.StylePrimitive{
35+ Color: stringPtr(dark.Yellow().Hex),
36+ Italic: boolPtr(true),
37+ Prefix: "┃ ",
38+ },
39+ Indent: uintPtr(1),
40+ Margin: uintPtr(defaultMargin),
41+ },
42+ List: ansi.StyleList{
43+ LevelIndent: defaultMargin,
44+ StyleBlock: ansi.StyleBlock{
45+ StylePrimitive: ansi.StylePrimitive{
46+ Color: stringPtr(dark.Text().Hex),
47+ },
48+ },
49+ },
50+ Heading: ansi.StyleBlock{
51+ StylePrimitive: ansi.StylePrimitive{
52+ BlockSuffix: "\n",
53+ Color: stringPtr(dark.Mauve().Hex),
54+ Bold: boolPtr(true),
55+ },
56+ },
57+ H1: ansi.StyleBlock{
58+ StylePrimitive: ansi.StylePrimitive{
59+ Prefix: "# ",
60+ Color: stringPtr(dark.Lavender().Hex),
61+ Bold: boolPtr(true),
62+ BlockPrefix: "\n",
63+ },
64+ },
65+ H2: ansi.StyleBlock{
66+ StylePrimitive: ansi.StylePrimitive{
67+ Prefix: "## ",
68+ Color: stringPtr(dark.Mauve().Hex),
69+ Bold: boolPtr(true),
70+ },
71+ },
72+ H3: ansi.StyleBlock{
73+ StylePrimitive: ansi.StylePrimitive{
74+ Prefix: "### ",
75+ Color: stringPtr(dark.Pink().Hex),
76+ Bold: boolPtr(true),
77+ },
78+ },
79+ H4: ansi.StyleBlock{
80+ StylePrimitive: ansi.StylePrimitive{
81+ Prefix: "#### ",
82+ Color: stringPtr(dark.Flamingo().Hex),
83+ Bold: boolPtr(true),
84+ },
85+ },
86+ H5: ansi.StyleBlock{
87+ StylePrimitive: ansi.StylePrimitive{
88+ Prefix: "##### ",
89+ Color: stringPtr(dark.Rosewater().Hex),
90+ Bold: boolPtr(true),
91+ },
92+ },
93+ H6: ansi.StyleBlock{
94+ StylePrimitive: ansi.StylePrimitive{
95+ Prefix: "###### ",
96+ Color: stringPtr(dark.Rosewater().Hex),
97+ Bold: boolPtr(true),
98+ },
99+ },
100+ Strikethrough: ansi.StylePrimitive{
101+ CrossedOut: boolPtr(true),
102+ Color: stringPtr(dark.Overlay1().Hex),
103+ },
104+ Emph: ansi.StylePrimitive{
105+ Color: stringPtr(dark.Yellow().Hex),
106+ Italic: boolPtr(true),
107+ },
108+ Strong: ansi.StylePrimitive{
109+ Bold: boolPtr(true),
110+ Color: stringPtr(dark.Peach().Hex),
111+ },
112+ HorizontalRule: ansi.StylePrimitive{
113+ Color: stringPtr(dark.Overlay0().Hex),
114+ Format: "\n─────────────────────────────────────────\n",
115+ },
116+ Item: ansi.StylePrimitive{
117+ BlockPrefix: "• ",
118+ Color: stringPtr(dark.Blue().Hex),
119+ },
120+ Enumeration: ansi.StylePrimitive{
121+ BlockPrefix: ". ",
122+ Color: stringPtr(dark.Sky().Hex),
123+ },
124+ Task: ansi.StyleTask{
125+ StylePrimitive: ansi.StylePrimitive{},
126+ Ticked: "[✓] ",
127+ Unticked: "[ ] ",
128+ },
129+ Link: ansi.StylePrimitive{
130+ Color: stringPtr(dark.Sky().Hex),
131+ Underline: boolPtr(true),
132+ },
133+ LinkText: ansi.StylePrimitive{
134+ Color: stringPtr(dark.Pink().Hex),
135+ Bold: boolPtr(true),
136+ },
137+ Image: ansi.StylePrimitive{
138+ Color: stringPtr(dark.Sapphire().Hex),
139+ Underline: boolPtr(true),
140+ Format: "🖼 {{.text}}",
141+ },
142+ ImageText: ansi.StylePrimitive{
143+ Color: stringPtr(dark.Pink().Hex),
144+ Format: "{{.text}}",
145+ },
146+ Code: ansi.StyleBlock{
147+ StylePrimitive: ansi.StylePrimitive{
148+ Color: stringPtr(dark.Green().Hex),
149+ Prefix: " ",
150+ Suffix: " ",
151+ },
152+ },
153+ CodeBlock: ansi.StyleCodeBlock{
154+ StyleBlock: ansi.StyleBlock{
155+ StylePrimitive: ansi.StylePrimitive{
156+ Prefix: " ",
157+ Color: stringPtr(dark.Text().Hex),
158+ },
159+
160+ Margin: uintPtr(defaultMargin),
161+ },
162+ Chroma: &ansi.Chroma{
163+ Text: ansi.StylePrimitive{
164+ Color: stringPtr(dark.Text().Hex),
165+ },
166+ Error: ansi.StylePrimitive{
167+ Color: stringPtr(dark.Text().Hex),
168+ },
169+ Comment: ansi.StylePrimitive{
170+ Color: stringPtr(dark.Overlay1().Hex),
171+ },
172+ CommentPreproc: ansi.StylePrimitive{
173+ Color: stringPtr(dark.Pink().Hex),
174+ },
175+ Keyword: ansi.StylePrimitive{
176+ Color: stringPtr(dark.Pink().Hex),
177+ },
178+ KeywordReserved: ansi.StylePrimitive{
179+ Color: stringPtr(dark.Pink().Hex),
180+ },
181+ KeywordNamespace: ansi.StylePrimitive{
182+ Color: stringPtr(dark.Pink().Hex),
183+ },
184+ KeywordType: ansi.StylePrimitive{
185+ Color: stringPtr(dark.Sky().Hex),
186+ },
187+ Operator: ansi.StylePrimitive{
188+ Color: stringPtr(dark.Pink().Hex),
189+ },
190+ Punctuation: ansi.StylePrimitive{
191+ Color: stringPtr(dark.Text().Hex),
192+ },
193+ Name: ansi.StylePrimitive{
194+ Color: stringPtr(dark.Sky().Hex),
195+ },
196+ NameBuiltin: ansi.StylePrimitive{
197+ Color: stringPtr(dark.Sky().Hex),
198+ },
199+ NameTag: ansi.StylePrimitive{
200+ Color: stringPtr(dark.Pink().Hex),
201+ },
202+ NameAttribute: ansi.StylePrimitive{
203+ Color: stringPtr(dark.Green().Hex),
204+ },
205+ NameClass: ansi.StylePrimitive{
206+ Color: stringPtr(dark.Sky().Hex),
207+ },
208+ NameConstant: ansi.StylePrimitive{
209+ Color: stringPtr(dark.Mauve().Hex),
210+ },
211+ NameDecorator: ansi.StylePrimitive{
212+ Color: stringPtr(dark.Green().Hex),
213+ },
214+ NameFunction: ansi.StylePrimitive{
215+ Color: stringPtr(dark.Green().Hex),
216+ },
217+ LiteralNumber: ansi.StylePrimitive{
218+ Color: stringPtr(dark.Teal().Hex),
219+ },
220+ LiteralString: ansi.StylePrimitive{
221+ Color: stringPtr(dark.Yellow().Hex),
222+ },
223+ LiteralStringEscape: ansi.StylePrimitive{
224+ Color: stringPtr(dark.Pink().Hex),
225+ },
226+ GenericDeleted: ansi.StylePrimitive{
227+ Color: stringPtr(dark.Red().Hex),
228+ },
229+ GenericEmph: ansi.StylePrimitive{
230+ Color: stringPtr(dark.Yellow().Hex),
231+ Italic: boolPtr(true),
232+ },
233+ GenericInserted: ansi.StylePrimitive{
234+ Color: stringPtr(dark.Green().Hex),
235+ },
236+ GenericStrong: ansi.StylePrimitive{
237+ Color: stringPtr(dark.Peach().Hex),
238+ Bold: boolPtr(true),
239+ },
240+ GenericSubheading: ansi.StylePrimitive{
241+ Color: stringPtr(dark.Mauve().Hex),
242+ },
243+ },
244+ },
245+ Table: ansi.StyleTable{
246+ StyleBlock: ansi.StyleBlock{
247+ StylePrimitive: ansi.StylePrimitive{
248+ BlockPrefix: "\n",
249+ BlockSuffix: "\n",
250+ },
251+ },
252+ CenterSeparator: stringPtr("┼"),
253+ ColumnSeparator: stringPtr("│"),
254+ RowSeparator: stringPtr("─"),
255+ },
256+ DefinitionDescription: ansi.StylePrimitive{
257+ BlockPrefix: "\n ❯ ",
258+ Color: stringPtr(dark.Sapphire().Hex),
259+ },
260+}
261+
262+var catppuccinLight = ansi.StyleConfig{
263+ Document: ansi.StyleBlock{
264+ StylePrimitive: ansi.StylePrimitive{
265+ BlockPrefix: "\n",
266+ BlockSuffix: "\n",
267+ Color: stringPtr(light.Text().Hex),
268+ },
269+ Margin: uintPtr(defaultMargin),
270+ },
271+ BlockQuote: ansi.StyleBlock{
272+ StylePrimitive: ansi.StylePrimitive{
273+ Color: stringPtr(light.Yellow().Hex),
274+ Italic: boolPtr(true),
275+ Prefix: "┃ ",
276+ },
277+ Indent: uintPtr(1),
278+ Margin: uintPtr(defaultMargin),
279+ },
280+ List: ansi.StyleList{
281+ LevelIndent: defaultMargin,
282+ StyleBlock: ansi.StyleBlock{
283+ StylePrimitive: ansi.StylePrimitive{
284+ Color: stringPtr(light.Text().Hex),
285+ },
286+ },
287+ },
288+ Heading: ansi.StyleBlock{
289+ StylePrimitive: ansi.StylePrimitive{
290+ BlockSuffix: "\n",
291+ Color: stringPtr(light.Mauve().Hex),
292+ Bold: boolPtr(true),
293+ },
294+ },
295+ H1: ansi.StyleBlock{
296+ StylePrimitive: ansi.StylePrimitive{
297+ Prefix: "# ",
298+ Color: stringPtr(light.Lavender().Hex),
299+ Bold: boolPtr(true),
300+ BlockPrefix: "\n",
301+ },
302+ },
303+ H2: ansi.StyleBlock{
304+ StylePrimitive: ansi.StylePrimitive{
305+ Prefix: "## ",
306+ Color: stringPtr(light.Mauve().Hex),
307+ Bold: boolPtr(true),
308+ },
309+ },
310+ H3: ansi.StyleBlock{
311+ StylePrimitive: ansi.StylePrimitive{
312+ Prefix: "### ",
313+ Color: stringPtr(light.Pink().Hex),
314+ Bold: boolPtr(true),
315+ },
316+ },
317+ H4: ansi.StyleBlock{
318+ StylePrimitive: ansi.StylePrimitive{
319+ Prefix: "#### ",
320+ Color: stringPtr(light.Flamingo().Hex),
321+ Bold: boolPtr(true),
322+ },
323+ },
324+ H5: ansi.StyleBlock{
325+ StylePrimitive: ansi.StylePrimitive{
326+ Prefix: "##### ",
327+ Color: stringPtr(light.Rosewater().Hex),
328+ Bold: boolPtr(true),
329+ },
330+ },
331+ H6: ansi.StyleBlock{
332+ StylePrimitive: ansi.StylePrimitive{
333+ Prefix: "###### ",
334+ Color: stringPtr(light.Rosewater().Hex),
335+ Bold: boolPtr(true),
336+ },
337+ },
338+ Strikethrough: ansi.StylePrimitive{
339+ CrossedOut: boolPtr(true),
340+ Color: stringPtr(light.Overlay1().Hex),
341+ },
342+ Emph: ansi.StylePrimitive{
343+ Color: stringPtr(light.Yellow().Hex),
344+ Italic: boolPtr(true),
345+ },
346+ Strong: ansi.StylePrimitive{
347+ Bold: boolPtr(true),
348+ Color: stringPtr(light.Peach().Hex),
349+ },
350+ HorizontalRule: ansi.StylePrimitive{
351+ Color: stringPtr(light.Overlay0().Hex),
352+ Format: "\n─────────────────────────────────────────\n",
353+ },
354+ Item: ansi.StylePrimitive{
355+ BlockPrefix: "• ",
356+ Color: stringPtr(light.Blue().Hex),
357+ },
358+ Enumeration: ansi.StylePrimitive{
359+ BlockPrefix: ". ",
360+ Color: stringPtr(light.Sky().Hex),
361+ },
362+ Task: ansi.StyleTask{
363+ StylePrimitive: ansi.StylePrimitive{},
364+ Ticked: "[✓] ",
365+ Unticked: "[ ] ",
366+ },
367+ Link: ansi.StylePrimitive{
368+ Color: stringPtr(light.Sky().Hex),
369+ Underline: boolPtr(true),
370+ },
371+ LinkText: ansi.StylePrimitive{
372+ Color: stringPtr(light.Pink().Hex),
373+ Bold: boolPtr(true),
374+ },
375+ Image: ansi.StylePrimitive{
376+ Color: stringPtr(light.Sapphire().Hex),
377+ Underline: boolPtr(true),
378+ Format: "🖼 {{.text}}",
379+ },
380+ ImageText: ansi.StylePrimitive{
381+ Color: stringPtr(light.Pink().Hex),
382+ Format: "{{.text}}",
383+ },
384+ Code: ansi.StyleBlock{
385+ StylePrimitive: ansi.StylePrimitive{
386+ Color: stringPtr(light.Green().Hex),
387+ Prefix: " ",
388+ Suffix: " ",
389+ },
390+ },
391+ CodeBlock: ansi.StyleCodeBlock{
392+ StyleBlock: ansi.StyleBlock{
393+ StylePrimitive: ansi.StylePrimitive{
394+ Prefix: " ",
395+ Color: stringPtr(light.Text().Hex),
396+ },
397+
398+ Margin: uintPtr(defaultMargin),
399+ },
400+ Chroma: &ansi.Chroma{
401+ Text: ansi.StylePrimitive{
402+ Color: stringPtr(light.Text().Hex),
403+ },
404+ Error: ansi.StylePrimitive{
405+ Color: stringPtr(light.Text().Hex),
406+ },
407+ Comment: ansi.StylePrimitive{
408+ Color: stringPtr(light.Overlay1().Hex),
409+ },
410+ CommentPreproc: ansi.StylePrimitive{
411+ Color: stringPtr(light.Pink().Hex),
412+ },
413+ Keyword: ansi.StylePrimitive{
414+ Color: stringPtr(light.Pink().Hex),
415+ },
416+ KeywordReserved: ansi.StylePrimitive{
417+ Color: stringPtr(light.Pink().Hex),
418+ },
419+ KeywordNamespace: ansi.StylePrimitive{
420+ Color: stringPtr(light.Pink().Hex),
421+ },
422+ KeywordType: ansi.StylePrimitive{
423+ Color: stringPtr(light.Sky().Hex),
424+ },
425+ Operator: ansi.StylePrimitive{
426+ Color: stringPtr(light.Pink().Hex),
427+ },
428+ Punctuation: ansi.StylePrimitive{
429+ Color: stringPtr(light.Text().Hex),
430+ },
431+ Name: ansi.StylePrimitive{
432+ Color: stringPtr(light.Sky().Hex),
433+ },
434+ NameBuiltin: ansi.StylePrimitive{
435+ Color: stringPtr(light.Sky().Hex),
436+ },
437+ NameTag: ansi.StylePrimitive{
438+ Color: stringPtr(light.Pink().Hex),
439+ },
440+ NameAttribute: ansi.StylePrimitive{
441+ Color: stringPtr(light.Green().Hex),
442+ },
443+ NameClass: ansi.StylePrimitive{
444+ Color: stringPtr(light.Sky().Hex),
445+ },
446+ NameConstant: ansi.StylePrimitive{
447+ Color: stringPtr(light.Mauve().Hex),
448+ },
449+ NameDecorator: ansi.StylePrimitive{
450+ Color: stringPtr(light.Green().Hex),
451+ },
452+ NameFunction: ansi.StylePrimitive{
453+ Color: stringPtr(light.Green().Hex),
454+ },
455+ LiteralNumber: ansi.StylePrimitive{
456+ Color: stringPtr(light.Teal().Hex),
457+ },
458+ LiteralString: ansi.StylePrimitive{
459+ Color: stringPtr(light.Yellow().Hex),
460+ },
461+ LiteralStringEscape: ansi.StylePrimitive{
462+ Color: stringPtr(light.Pink().Hex),
463+ },
464+ GenericDeleted: ansi.StylePrimitive{
465+ Color: stringPtr(light.Red().Hex),
466+ },
467+ GenericEmph: ansi.StylePrimitive{
468+ Color: stringPtr(light.Yellow().Hex),
469+ Italic: boolPtr(true),
470+ },
471+ GenericInserted: ansi.StylePrimitive{
472+ Color: stringPtr(light.Green().Hex),
473+ },
474+ GenericStrong: ansi.StylePrimitive{
475+ Color: stringPtr(light.Peach().Hex),
476+ Bold: boolPtr(true),
477+ },
478+ GenericSubheading: ansi.StylePrimitive{
479+ Color: stringPtr(light.Mauve().Hex),
480+ },
481+ },
482+ },
483+ Table: ansi.StyleTable{
484+ StyleBlock: ansi.StyleBlock{
485+ StylePrimitive: ansi.StylePrimitive{
486+ BlockPrefix: "\n",
487+ BlockSuffix: "\n",
488+ },
489+ },
490+ CenterSeparator: stringPtr("┼"),
491+ ColumnSeparator: stringPtr("│"),
492+ RowSeparator: stringPtr("─"),
493+ },
494+ DefinitionDescription: ansi.StylePrimitive{
495+ BlockPrefix: "\n ❯ ",
496+ Color: stringPtr(light.Sapphire().Hex),
497+ },
498+}
internal/tui/styles/styles.gonew file+121
@@ -0,0 +1,121 @@
1+package styles
2+
3+import (
4+ catppuccin "github.com/catppuccin/go"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+var (
9+ light = catppuccin.Latte
10+ dark = catppuccin.Mocha
11+)
12+
13+var (
14+ Regular = lipgloss.NewStyle()
15+ Bold = Regular.Bold(true)
16+ Padded = Regular.Padding(0, 1)
17+
18+ Border = Regular.Border(lipgloss.NormalBorder())
19+ ThickBorder = Regular.Border(lipgloss.ThickBorder())
20+ DoubleBorder = Regular.Border(lipgloss.DoubleBorder())
21+ // Colors
22+
23+ Surface0 = lipgloss.AdaptiveColor{
24+ Dark: dark.Surface0().Hex,
25+ Light: light.Surface0().Hex,
26+ }
27+
28+ Overlay0 = lipgloss.AdaptiveColor{
29+ Dark: dark.Overlay0().Hex,
30+ Light: light.Overlay0().Hex,
31+ }
32+
33+ Ovelay1 = lipgloss.AdaptiveColor{
34+ Dark: dark.Overlay1().Hex,
35+ Light: light.Overlay1().Hex,
36+ }
37+
38+ Text = lipgloss.AdaptiveColor{
39+ Dark: dark.Text().Hex,
40+ Light: light.Text().Hex,
41+ }
42+
43+ SubText0 = lipgloss.AdaptiveColor{
44+ Dark: dark.Subtext0().Hex,
45+ Light: light.Subtext0().Hex,
46+ }
47+
48+ SubText1 = lipgloss.AdaptiveColor{
49+ Dark: dark.Subtext1().Hex,
50+ Light: light.Subtext1().Hex,
51+ }
52+
53+ LightGrey = lipgloss.AdaptiveColor{
54+ Dark: dark.Surface0().Hex,
55+ Light: light.Surface0().Hex,
56+ }
57+ Grey = lipgloss.AdaptiveColor{
58+ Dark: dark.Surface1().Hex,
59+ Light: light.Surface1().Hex,
60+ }
61+
62+ DarkGrey = lipgloss.AdaptiveColor{
63+ Dark: dark.Surface2().Hex,
64+ Light: light.Surface2().Hex,
65+ }
66+
67+ Base = lipgloss.AdaptiveColor{
68+ Dark: dark.Base().Hex,
69+ Light: light.Base().Hex,
70+ }
71+
72+ Crust = lipgloss.AdaptiveColor{
73+ Dark: dark.Crust().Hex,
74+ Light: light.Crust().Hex,
75+ }
76+
77+ Blue = lipgloss.AdaptiveColor{
78+ Dark: dark.Blue().Hex,
79+ Light: light.Blue().Hex,
80+ }
81+
82+ Red = lipgloss.AdaptiveColor{
83+ Dark: dark.Red().Hex,
84+ Light: light.Red().Hex,
85+ }
86+
87+ Green = lipgloss.AdaptiveColor{
88+ Dark: dark.Green().Hex,
89+ Light: light.Green().Hex,
90+ }
91+
92+ Mauve = lipgloss.AdaptiveColor{
93+ Dark: dark.Mauve().Hex,
94+ Light: light.Mauve().Hex,
95+ }
96+
97+ Teal = lipgloss.AdaptiveColor{
98+ Dark: dark.Teal().Hex,
99+ Light: light.Teal().Hex,
100+ }
101+
102+ Rosewater = lipgloss.AdaptiveColor{
103+ Dark: dark.Rosewater().Hex,
104+ Light: light.Rosewater().Hex,
105+ }
106+
107+ Flamingo = lipgloss.AdaptiveColor{
108+ Dark: dark.Flamingo().Hex,
109+ Light: light.Flamingo().Hex,
110+ }
111+
112+ Lavender = lipgloss.AdaptiveColor{
113+ Dark: dark.Lavender().Hex,
114+ Light: light.Lavender().Hex,
115+ }
116+
117+ Peach = lipgloss.AdaptiveColor{
118+ Dark: dark.Peach().Hex,
119+ Light: light.Peach().Hex,
120+ }
121+)
internal/tui/tui.gonew file+99
@@ -0,0 +1,99 @@
1+package tui
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
7+ "github.com/kujtimiihoxha/termai/internal/tui/page"
8+)
9+
10+type keyMap struct {
11+ Logs key.Binding
12+ Back key.Binding
13+ Quit key.Binding
14+}
15+
16+var keys = keyMap{
17+ Logs: key.NewBinding(
18+ key.WithKeys("L"),
19+ key.WithHelp("L", "logs"),
20+ ),
21+ Back: key.NewBinding(
22+ key.WithKeys("esc"),
23+ key.WithHelp("esc", "back"),
24+ ),
25+ Quit: key.NewBinding(
26+ key.WithKeys("ctrl+c", "q"),
27+ key.WithHelp("ctrl+c/q", "quit"),
28+ ),
29+}
30+
31+type appModel struct {
32+ width, height int
33+ currentPage page.PageID
34+ previousPage page.PageID
35+ pages map[page.PageID]tea.Model
36+ loadedPages map[page.PageID]bool
37+}
38+
39+func (a appModel) Init() tea.Cmd {
40+ cmd := a.pages[a.currentPage].Init()
41+ a.loadedPages[a.currentPage] = true
42+ return cmd
43+}
44+
45+func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+ switch msg := msg.(type) {
47+ case tea.WindowSizeMsg:
48+ a.width, a.height = msg.Width, msg.Height
49+ case tea.KeyMsg:
50+ if key.Matches(msg, keys.Quit) {
51+ return a, tea.Quit
52+ }
53+ if key.Matches(msg, keys.Back) {
54+ if a.previousPage != "" {
55+ return a, a.moveToPage(a.previousPage)
56+ }
57+ return a, nil
58+ }
59+ if key.Matches(msg, keys.Logs) {
60+ return a, a.moveToPage(page.LogsPage)
61+ }
62+ }
63+ p, cmd := a.pages[a.currentPage].Update(msg)
64+ if p != nil {
65+ a.pages[a.currentPage] = p
66+ }
67+ return a, cmd
68+}
69+
70+func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
71+ var cmd tea.Cmd
72+ if _, ok := a.loadedPages[pageID]; !ok {
73+ cmd = a.pages[pageID].Init()
74+ a.loadedPages[pageID] = true
75+ }
76+ a.previousPage = a.currentPage
77+ a.currentPage = pageID
78+ if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
79+ sizable.SetSize(a.width, a.height)
80+ }
81+
82+ return cmd
83+}
84+
85+func (a appModel) View() string {
86+ return a.pages[a.currentPage].View()
87+}
88+
89+func New() tea.Model {
90+ return &appModel{
91+ currentPage: page.ReplPage,
92+ loadedPages: make(map[page.PageID]bool),
93+ pages: map[page.PageID]tea.Model{
94+ page.LogsPage: page.NewLogsPage(),
95+ page.InitPage: page.NewInitPage(),
96+ page.ReplPage: page.NewReplPage(),
97+ },
98+ }
99+}