on main help sign in
add initial stuff
commit8daa6e774a6e02698c90392e7b2008542f789594 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSun 23 Mar 2025, 19:19 (2 years ago) parent796bbf4
36 files changed+1778−142
.sqlfluff+2 LICENSE cmd/root.go+126 cmd/termai/main.go−56 go.mod+32−5 go.sum+87−2 internal/app/services.go+31 internal/db/connect.go+93 internal/db/db.go+128 internal/db/embed.go+6 internal/db/migrations/000001_initial.down.sql+4 internal/db/migrations/000001_initial.up.sql+17 internal/db/models.go+15 internal/db/querier.go+20 internal/db/sessions.sql.go+165 internal/db/sql/sessions.sql+43 internal/pubsub/events.go+6 internal/session/session.go+116 internal/tui/components/core/dialog.go+84 internal/tui/components/core/help.go+11−11 internal/tui/components/dialog/quit.go+111 internal/tui/components/repl/editor.go+118−9 internal/tui/components/repl/messages.go+11−4 internal/tui/components/repl/sessions.go+161 internal/tui/components/repl/threads.go−21 internal/tui/layout/bento.go+15−5 internal/tui/layout/border.go+5−2 internal/tui/layout/overlay.go+205 internal/tui/layout/single.go+18−5 internal/tui/page/repl.go+5−4 internal/tui/styles/huh.go+46 internal/tui/styles/styles.go+3 internal/tui/tui.go+62−18 internal/tui/util/util.go+7 main.go+11 sqlc.yaml+14
.sqlfluffnew file+2
@@ -0,0 +1,2 @@
1+[sqlfluff:rules]
2+exclude_rules = AM04
LICENSEnew file

No changes to the content.

cmd/root.gonew file+126
@@ -0,0 +1,126 @@
1+/*
2+Copyright © 2025 NAME HERE <EMAIL ADDRESS>
3+*/
4+package cmd
5+
6+import (
7+ "context"
8+ "os"
9+ "sync"
10+
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/kujtimiihoxha/termai/internal/app"
13+ "github.com/kujtimiihoxha/termai/internal/db"
14+ "github.com/kujtimiihoxha/termai/internal/tui"
15+ "github.com/spf13/cobra"
16+ "github.com/spf13/viper"
17+)
18+
19+var rootCmd = &cobra.Command{
20+ Use: "termai",
21+ Short: "A terminal ai assistant",
22+ Long: `A terminal ai assistant`,
23+ RunE: func(cmd *cobra.Command, args []string) error {
24+ if cmd.Flag("help").Changed {
25+ cmd.Help()
26+ return nil
27+ }
28+ debug, _ := cmd.Flags().GetBool("debug")
29+ viper.Set("debug", debug)
30+ if debug {
31+ viper.Set("log.level", "debug")
32+ }
33+
34+ conn, err := db.Connect()
35+ if err != nil {
36+ return err
37+ }
38+ ctx := context.Background()
39+
40+ app := app.New(ctx, conn)
41+ app.Logger.Info("Starting termai...")
42+ tui := tea.NewProgram(
43+ tui.New(app),
44+ tea.WithAltScreen(),
45+ )
46+ app.Logger.Info("Setting up subscriptions...")
47+ ch, unsub := setupSubscriptions(app)
48+ defer unsub()
49+
50+ go func() {
51+ for msg := range ch {
52+ tui.Send(msg)
53+ }
54+ }()
55+ if _, err := tui.Run(); err != nil {
56+ return err
57+ }
58+ return nil
59+ },
60+}
61+
62+func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
63+ ch := make(chan tea.Msg)
64+ wg := sync.WaitGroup{}
65+ ctx, cancel := context.WithCancel(app.Context)
66+
67+ if viper.GetBool("debug") {
68+ sub := app.Logger.Subscribe(ctx)
69+ wg.Add(1)
70+ go func() {
71+ for ev := range sub {
72+ ch <- ev
73+ }
74+ wg.Done()
75+ }()
76+ }
77+ {
78+ sub := app.Sessions.Subscribe(ctx)
79+ wg.Add(1)
80+ go func() {
81+ for ev := range sub {
82+ ch <- ev
83+ }
84+ wg.Done()
85+ }()
86+ }
87+ return ch, func() {
88+ cancel()
89+ wg.Wait()
90+ close(ch)
91+ }
92+}
93+
94+// Execute adds all child commands to the root command and sets flags appropriately.
95+// This is called by main.main(). It only needs to happen once to the rootCmd.
96+func Execute() {
97+ err := rootCmd.Execute()
98+ if err != nil {
99+ os.Exit(1)
100+ }
101+}
102+
103+func loadConfig() {
104+ viper.SetConfigName(".termai")
105+ viper.SetConfigType("yaml")
106+ viper.AddConfigPath("$HOME")
107+ viper.AddConfigPath("$XDG_CONFIG_HOME/termai")
108+ viper.AddConfigPath(".")
109+ viper.SetEnvPrefix("TERMAI")
110+ // SET DEFAULTS
111+ viper.SetDefault("log.level", "info")
112+ viper.SetDefault("data.dir", ".termai")
113+
114+ //
115+ viper.ReadInConfig()
116+}
117+
118+func init() {
119+ loadConfig()
120+ // Here you will define your flags and configuration settings.
121+ // Cobra supports persistent flags, which, if defined here,
122+ // will be global for your application.
123+
124+ rootCmd.Flags().BoolP("help", "h", false, "Help")
125+ rootCmd.Flags().BoolP("debug", "d", false, "Help")
126+}
cmd/termai/main.godeleted−56
@@ -1,56 +0,0 @@
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− return ch, func() {
52− cancel()
53− wg.Wait()
54− close(ch)
55− }
56−}
go.mod+32−5
@@ -7,37 +7,64 @@ require (
77 github.com/charmbracelet/bubbles v0.20.0
88 github.com/charmbracelet/bubbletea v1.3.4
99 github.com/charmbracelet/glamour v0.9.1
10+ github.com/charmbracelet/huh v0.6.0
1011 github.com/charmbracelet/lipgloss v1.1.0
1112 github.com/go-logfmt/logfmt v0.6.0
12− golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
13+ github.com/golang-migrate/migrate/v4 v4.18.2
14+ github.com/google/uuid v1.6.0
15+ github.com/mattn/go-runewidth v0.0.16
16+ github.com/mattn/go-sqlite3 v1.14.24
17+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
18+ github.com/muesli/reflow v0.3.0
19+ github.com/muesli/termenv v0.16.0
20+ github.com/spf13/cobra v1.9.1
21+ github.com/spf13/viper v1.20.0
22+ golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0
1323)
1424
1525require (
1626 github.com/alecthomas/chroma/v2 v2.15.0 // indirect
27+ github.com/atotto/clipboard v0.1.4 // indirect
1728 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1829 github.com/aymerick/douceur v0.2.0 // indirect
1930 github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
2031 github.com/charmbracelet/x/ansi v0.8.0 // indirect
2132 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
33+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
2234 github.com/charmbracelet/x/term v0.2.1 // indirect
2335 github.com/dlclark/regexp2 v1.11.4 // indirect
36+ github.com/dustin/go-humanize v1.0.1 // indirect
2437 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
38+ github.com/fsnotify/fsnotify v1.8.0 // indirect
39+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
2540 github.com/gorilla/css v1.0.1 // indirect
41+ github.com/hashicorp/errwrap v1.1.0 // indirect
42+ github.com/hashicorp/go-multierror v1.1.1 // indirect
43+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
44+ github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840 // indirect
2645 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
2746 github.com/mattn/go-isatty v0.0.20 // indirect
2847 github.com/mattn/go-localereader v0.0.1 // indirect
29− github.com/mattn/go-runewidth v0.0.16 // indirect
3048 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
31− github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
49+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
3250 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
51+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
3552 github.com/rivo/uniseg v0.4.7 // indirect
53+ github.com/sagikazarmark/locafero v0.7.0 // indirect
54+ github.com/sahilm/fuzzy v0.1.1 // indirect
55+ github.com/sourcegraph/conc v0.3.0 // indirect
56+ github.com/spf13/afero v1.12.0 // indirect
57+ github.com/spf13/cast v1.7.1 // indirect
58+ github.com/spf13/pflag v1.0.6 // indirect
59+ github.com/subosito/gotenv v1.6.0 // indirect
3660 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3761 github.com/yuin/goldmark v1.7.8 // indirect
3862 github.com/yuin/goldmark-emoji v1.0.5 // indirect
63+ go.uber.org/atomic v1.9.0 // indirect
64+ go.uber.org/multierr v1.9.0 // indirect
3965 golang.org/x/net v0.33.0 // indirect
4066 golang.org/x/sync v0.12.0 // indirect
4167 golang.org/x/sys v0.30.0 // indirect
4268 golang.org/x/text v0.23.0 // indirect
69+ gopkg.in/yaml.v3 v3.0.1 // indirect
4370)
go.sum+87−2
@@ -1,9 +1,13 @@
1+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
2+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
13github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
24github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
35github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
46github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
57github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
68github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
9+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
10+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
711github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
812github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
913github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -20,6 +24,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
2024github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
2125github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
2226github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
27+github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
28+github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
2329github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
2430github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
2531github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
@@ -28,18 +34,55 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
2834github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
2935github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
3036github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
37+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
38+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
3139github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
3240github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
41+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
42+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
43+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
44+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3345github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
3446github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
47+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
48+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3549github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3650github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
51+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
52+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
53+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
54+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
3755github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
3856github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
57+github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
58+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
59+github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
60+github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
61+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
62+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
63+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
64+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3965github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
4066github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
67+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
68+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
69+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
70+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
71+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
4172github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
4273github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
74+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
75+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
76+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
77+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
78+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
79+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
80+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840 h1:AORwYXTzap8hg0zmTA5RWB/0fxv9F19dF42dCY0IsRc=
81+github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840/go.mod h1:VyCD1xYnYem+OHp9nzGNx8x7rCwaeB+2VSyOUgX8Zyc=
82+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
83+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
84+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
85+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
4386github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
4487github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
4588github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -49,8 +92,12 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
4992github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
5093github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
5194github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
95+github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
96+github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
5297github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
5398github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
99+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
100+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
54101github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
55102github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
56103github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -59,10 +106,39 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
59106github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
60107github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
61108github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
109+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
110+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
111+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
112+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
62113github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
63114github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
64115github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
65116github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
117+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
118+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
119+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
120+github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
121+github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
122+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
123+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
124+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
125+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
126+github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
127+github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
128+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
129+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
130+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
131+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
132+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
133+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
134+github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
135+github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
136+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
137+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
138+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
139+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
140+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
141+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
66142github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
67143github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
68144github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
@@ -70,8 +146,12 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
70146github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
71147github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
72148github.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=
149+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
150+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
151+go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
152+go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
153+golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
154+golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
75155golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
76156golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
77157golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
@@ -82,3 +162,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
82162golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
83163golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
84164golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
165+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
166+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
167+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
168+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
169+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
internal/app/services.gonew file+31
@@ -0,0 +1,31 @@
1+package app
2+
3+import (
4+ "context"
5+ "database/sql"
6+
7+ "github.com/kujtimiihoxha/termai/internal/db"
8+ "github.com/kujtimiihoxha/termai/internal/logging"
9+ "github.com/kujtimiihoxha/termai/internal/session"
10+ "github.com/spf13/viper"
11+)
12+
13+type App struct {
14+ Context context.Context
15+
16+ Sessions session.Service
17+
18+ Logger logging.Interface
19+}
20+
21+func New(ctx context.Context, conn *sql.DB) *App {
22+ q := db.New(conn)
23+ log := logging.NewLogger(logging.Options{
24+ Level: viper.GetString("log.level"),
25+ })
26+ return &App{
27+ Context: ctx,
28+ Sessions: session.NewService(ctx, q),
29+ Logger: log,
30+ }
31+}
internal/db/connect.gonew file+93
@@ -0,0 +1,93 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/golang-migrate/migrate/v4"
10+ "github.com/golang-migrate/migrate/v4/source/iofs"
11+
12+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
13+ _ "github.com/mattn/go-sqlite3"
14+
15+ "github.com/kujtimiihoxha/termai/internal/logging"
16+ "github.com/spf13/viper"
17+)
18+
19+var log = logging.Get()
20+
21+func Connect() (*sql.DB, error) {
22+ dataDir := viper.GetString("data.dir")
23+ if dataDir == "" {
24+ return nil, fmt.Errorf("data.dir is not set")
25+ }
26+ if err := os.MkdirAll(dataDir, 0o700); err != nil {
27+ return nil, fmt.Errorf("failed to create data directory: %w", err)
28+ }
29+ dbPath := filepath.Join(dataDir, "termai.db")
30+ // Open the SQLite database
31+ db, err := sql.Open("sqlite3", dbPath)
32+ if err != nil {
33+ return nil, fmt.Errorf("failed to open database: %w", err)
34+ }
35+
36+ // Verify connection
37+ if err = db.Ping(); err != nil {
38+ db.Close()
39+ return nil, fmt.Errorf("failed to connect to database: %w", err)
40+ }
41+
42+ // Set pragmas for better performance
43+ pragmas := []string{
44+ "PRAGMA foreign_keys = ON;",
45+ "PRAGMA journal_mode = WAL;",
46+ "PRAGMA page_size = 4096;",
47+ "PRAGMA cache_size = -8000;",
48+ "PRAGMA synchronous = NORMAL;",
49+ }
50+
51+ for _, pragma := range pragmas {
52+ if _, err = db.Exec(pragma); err != nil {
53+ log.Warn("Failed to set pragma", pragma, err)
54+ } else {
55+ log.Warn("Set pragma", "pragma", pragma)
56+ }
57+ }
58+
59+ // Initialize schema from embedded file
60+ d, err := iofs.New(FS, "migrations")
61+ if err != nil {
62+ log.Error("Failed to open embedded migrations", "error", err)
63+ db.Close()
64+ return nil, fmt.Errorf("failed to open embedded migrations: %w", err)
65+ }
66+
67+ driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
68+ if err != nil {
69+ log.Error("Failed to create SQLite driver", "error", err)
70+ db.Close()
71+ return nil, fmt.Errorf("failed to create SQLite driver: %w", err)
72+ }
73+
74+ m, err := migrate.NewWithInstance("iofs", d, "ql", driver)
75+ if err != nil {
76+ log.Error("Failed to create migration instance", "error", err)
77+ db.Close()
78+ return nil, fmt.Errorf("failed to create migration instance: %w", err)
79+ }
80+
81+ err = m.Up()
82+ if err != nil && err != migrate.ErrNoChange {
83+ log.Error("Migration failed", "error", err)
84+ db.Close()
85+ return nil, fmt.Errorf("failed to apply schema: %w", err)
86+ } else if err == migrate.ErrNoChange {
87+ log.Info("No schema changes to apply")
88+ } else {
89+ log.Info("Schema migration applied successfully")
90+ }
91+
92+ return db, nil
93+}
internal/db/db.gonew file+128
@@ -0,0 +1,128 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+
5+package db
6+
7+import (
8+ "context"
9+ "database/sql"
10+ "fmt"
11+)
12+
13+type DBTX interface {
14+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
15+ PrepareContext(context.Context, string) (*sql.Stmt, error)
16+ QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
17+ QueryRowContext(context.Context, string, ...interface{}) *sql.Row
18+}
19+
20+func New(db DBTX) *Queries {
21+ return &Queries{db: db}
22+}
23+
24+func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
25+ q := Queries{db: db}
26+ var err error
27+ if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
28+ return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
29+ }
30+ if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
31+ return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
32+ }
33+ if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
34+ return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
35+ }
36+ if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
37+ return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
38+ }
39+ if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
40+ return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
41+ }
42+ return &q, nil
43+}
44+
45+func (q *Queries) Close() error {
46+ var err error
47+ if q.createSessionStmt != nil {
48+ if cerr := q.createSessionStmt.Close(); cerr != nil {
49+ err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
50+ }
51+ }
52+ if q.deleteSessionStmt != nil {
53+ if cerr := q.deleteSessionStmt.Close(); cerr != nil {
54+ err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
55+ }
56+ }
57+ if q.getSessionByIDStmt != nil {
58+ if cerr := q.getSessionByIDStmt.Close(); cerr != nil {
59+ err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
60+ }
61+ }
62+ if q.listSessionsStmt != nil {
63+ if cerr := q.listSessionsStmt.Close(); cerr != nil {
64+ err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
65+ }
66+ }
67+ if q.updateSessionStmt != nil {
68+ if cerr := q.updateSessionStmt.Close(); cerr != nil {
69+ err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
70+ }
71+ }
72+ return err
73+}
74+
75+func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) {
76+ switch {
77+ case stmt != nil && q.tx != nil:
78+ return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...)
79+ case stmt != nil:
80+ return stmt.ExecContext(ctx, args...)
81+ default:
82+ return q.db.ExecContext(ctx, query, args...)
83+ }
84+}
85+
86+func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) {
87+ switch {
88+ case stmt != nil && q.tx != nil:
89+ return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...)
90+ case stmt != nil:
91+ return stmt.QueryContext(ctx, args...)
92+ default:
93+ return q.db.QueryContext(ctx, query, args...)
94+ }
95+}
96+
97+func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row {
98+ switch {
99+ case stmt != nil && q.tx != nil:
100+ return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...)
101+ case stmt != nil:
102+ return stmt.QueryRowContext(ctx, args...)
103+ default:
104+ return q.db.QueryRowContext(ctx, query, args...)
105+ }
106+}
107+
108+type Queries struct {
109+ db DBTX
110+ tx *sql.Tx
111+ createSessionStmt *sql.Stmt
112+ deleteSessionStmt *sql.Stmt
113+ getSessionByIDStmt *sql.Stmt
114+ listSessionsStmt *sql.Stmt
115+ updateSessionStmt *sql.Stmt
116+}
117+
118+func (q *Queries) WithTx(tx *sql.Tx) *Queries {
119+ return &Queries{
120+ db: tx,
121+ tx: tx,
122+ createSessionStmt: q.createSessionStmt,
123+ deleteSessionStmt: q.deleteSessionStmt,
124+ getSessionByIDStmt: q.getSessionByIDStmt,
125+ listSessionsStmt: q.listSessionsStmt,
126+ updateSessionStmt: q.updateSessionStmt,
127+ }
128+}
internal/db/embed.gonew file+6
@@ -0,0 +1,6 @@
1+package db
2+
3+import "embed"
4+
5+//go:embed migrations/*.sql
6+var FS embed.FS
internal/db/migrations/000001_initial.down.sqlnew file+4
@@ -0,0 +1,4 @@
1+-- sqlfluff:dialect:sqlite
2+DROP TRIGGER IF EXISTS update_sessions_updated_at;
3+
4+DROP TABLE IF EXISTS sessions;
internal/db/migrations/000001_initial.up.sqlnew file+17
@@ -0,0 +1,17 @@
1+-- sqlfluff:dialect:sqlite
2+CREATE TABLE IF NOT EXISTS sessions (
3+ id TEXT PRIMARY KEY,
4+ title TEXT NOT NULL,
5+ message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
6+ tokens INTEGER NOT NULL DEFAULT 0 CHECK (tokens >= 0),
7+ cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
8+ updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
9+ created_at INTEGER NOT NULL -- Unix timestamp in milliseconds
10+);
11+
12+CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
13+AFTER UPDATE ON sessions
14+BEGIN
15+UPDATE sessions SET updated_at = strftime('%s', 'now')
16+WHERE id = new.id;
17+END;
internal/db/models.gonew file+15
@@ -0,0 +1,15 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+
5+package db
6+
7+type Session struct {
8+ ID string `json:"id"`
9+ Title string `json:"title"`
10+ MessageCount int64 `json:"message_count"`
11+ Tokens int64 `json:"tokens"`
12+ Cost float64 `json:"cost"`
13+ UpdatedAt int64 `json:"updated_at"`
14+ CreatedAt int64 `json:"created_at"`
15+}
internal/db/querier.gonew file+20
@@ -0,0 +1,20 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+
5+package db
6+
7+import (
8+ "context"
9+)
10+
11+type Querier interface {
12+ // sqlfluff:dialect:sqlite
13+ CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
14+ DeleteSession(ctx context.Context, id string) error
15+ GetSessionByID(ctx context.Context, id string) (Session, error)
16+ ListSessions(ctx context.Context) ([]Session, error)
17+ UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
18+}
19+
20+var _ Querier = (*Queries)(nil)
internal/db/sessions.sql.gonew file+165
@@ -0,0 +1,165 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.27.0
4+// source: sessions.sql
5+
6+package db
7+
8+import (
9+ "context"
10+)
11+
12+const createSession = `-- name: CreateSession :one
13+INSERT INTO sessions (
14+ id,
15+ title,
16+ message_count,
17+ tokens,
18+ cost,
19+ updated_at,
20+ created_at
21+) VALUES (
22+ ?,
23+ ?,
24+ ?,
25+ ?,
26+ ?,
27+ strftime('%s', 'now'),
28+ strftime('%s', 'now')
29+) RETURNING id, title, message_count, tokens, cost, updated_at, created_at
30+`
31+
32+type CreateSessionParams struct {
33+ ID string `json:"id"`
34+ Title string `json:"title"`
35+ MessageCount int64 `json:"message_count"`
36+ Tokens int64 `json:"tokens"`
37+ Cost float64 `json:"cost"`
38+}
39+
40+// sqlfluff:dialect:sqlite
41+func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
42+ row := q.queryRow(ctx, q.createSessionStmt, createSession,
43+ arg.ID,
44+ arg.Title,
45+ arg.MessageCount,
46+ arg.Tokens,
47+ arg.Cost,
48+ )
49+ var i Session
50+ err := row.Scan(
51+ &i.ID,
52+ &i.Title,
53+ &i.MessageCount,
54+ &i.Tokens,
55+ &i.Cost,
56+ &i.UpdatedAt,
57+ &i.CreatedAt,
58+ )
59+ return i, err
60+}
61+
62+const deleteSession = `-- name: DeleteSession :exec
63+DELETE FROM sessions
64+WHERE id = ?
65+`
66+
67+func (q *Queries) DeleteSession(ctx context.Context, id string) error {
68+ _, err := q.exec(ctx, q.deleteSessionStmt, deleteSession, id)
69+ return err
70+}
71+
72+const getSessionByID = `-- name: GetSessionByID :one
73+SELECT id, title, message_count, tokens, cost, updated_at, created_at
74+FROM sessions
75+WHERE id = ? LIMIT 1
76+`
77+
78+func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
79+ row := q.queryRow(ctx, q.getSessionByIDStmt, getSessionByID, id)
80+ var i Session
81+ err := row.Scan(
82+ &i.ID,
83+ &i.Title,
84+ &i.MessageCount,
85+ &i.Tokens,
86+ &i.Cost,
87+ &i.UpdatedAt,
88+ &i.CreatedAt,
89+ )
90+ return i, err
91+}
92+
93+const listSessions = `-- name: ListSessions :many
94+SELECT id, title, message_count, tokens, cost, updated_at, created_at
95+FROM sessions
96+ORDER BY created_at DESC
97+`
98+
99+func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
100+ rows, err := q.query(ctx, q.listSessionsStmt, listSessions)
101+ if err != nil {
102+ return nil, err
103+ }
104+ defer rows.Close()
105+ items := []Session{}
106+ for rows.Next() {
107+ var i Session
108+ if err := rows.Scan(
109+ &i.ID,
110+ &i.Title,
111+ &i.MessageCount,
112+ &i.Tokens,
113+ &i.Cost,
114+ &i.UpdatedAt,
115+ &i.CreatedAt,
116+ ); err != nil {
117+ return nil, err
118+ }
119+ items = append(items, i)
120+ }
121+ if err := rows.Close(); err != nil {
122+ return nil, err
123+ }
124+ if err := rows.Err(); err != nil {
125+ return nil, err
126+ }
127+ return items, nil
128+}
129+
130+const updateSession = `-- name: UpdateSession :one
131+UPDATE sessions
132+SET
133+ title = ?,
134+ tokens = ?,
135+ cost = ?
136+WHERE id = ?
137+RETURNING id, title, message_count, tokens, cost, updated_at, created_at
138+`
139+
140+type UpdateSessionParams struct {
141+ Title string `json:"title"`
142+ Tokens int64 `json:"tokens"`
143+ Cost float64 `json:"cost"`
144+ ID string `json:"id"`
145+}
146+
147+func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
148+ row := q.queryRow(ctx, q.updateSessionStmt, updateSession,
149+ arg.Title,
150+ arg.Tokens,
151+ arg.Cost,
152+ arg.ID,
153+ )
154+ var i Session
155+ err := row.Scan(
156+ &i.ID,
157+ &i.Title,
158+ &i.MessageCount,
159+ &i.Tokens,
160+ &i.Cost,
161+ &i.UpdatedAt,
162+ &i.CreatedAt,
163+ )
164+ return i, err
165+}
internal/db/sql/sessions.sqlnew file+43
@@ -0,0 +1,43 @@
1+-- sqlfluff:dialect:sqlite
2+-- name: CreateSession :one
3+INSERT INTO sessions (
4+ id,
5+ title,
6+ message_count,
7+ tokens,
8+ cost,
9+ updated_at,
10+ created_at
11+) VALUES (
12+ ?,
13+ ?,
14+ ?,
15+ ?,
16+ ?,
17+ strftime('%s', 'now'),
18+ strftime('%s', 'now')
19+) RETURNING *;
20+
21+-- name: GetSessionByID :one
22+SELECT *
23+FROM sessions
24+WHERE id = ? LIMIT 1;
25+
26+-- name: ListSessions :many
27+SELECT *
28+FROM sessions
29+ORDER BY created_at DESC;
30+
31+-- name: UpdateSession :one
32+UPDATE sessions
33+SET
34+ title = ?,
35+ tokens = ?,
36+ cost = ?
37+WHERE id = ?
38+RETURNING *;
39+
40+
41+-- name: DeleteSession :exec
42+DELETE FROM sessions
43+WHERE id = ?;
internal/pubsub/events.go+6
@@ -1,11 +1,17 @@
11package pubsub
22
3+import "context"
4+
35const (
46 CreatedEvent EventType = "created"
57 UpdatedEvent EventType = "updated"
68 DeletedEvent EventType = "deleted"
79)
810
11+type Suscriber[T any] interface {
12+ Subscribe(context.Context) <-chan Event[T]
13+}
14+
915type (
1016 // EventType identifies the type of event
1117 EventType string
internal/session/session.gonew file+116
@@ -0,0 +1,116 @@
1+package session
2+
3+import (
4+ "context"
5+
6+ "github.com/google/uuid"
7+ "github.com/kujtimiihoxha/termai/internal/db"
8+ "github.com/kujtimiihoxha/termai/internal/pubsub"
9+)
10+
11+type Session struct {
12+ ID string
13+ Title string
14+ MessageCount int64
15+ Tokens int64
16+ Cost float64
17+ CreatedAt int64
18+ UpdatedAt int64
19+}
20+
21+type Service interface {
22+ pubsub.Suscriber[Session]
23+ Create(title string) (Session, error)
24+ Get(id string) (Session, error)
25+ List() ([]Session, error)
26+ Save(session Session) (Session, error)
27+ Delete(id string) error
28+}
29+
30+type service struct {
31+ *pubsub.Broker[Session]
32+ q db.Querier
33+ ctx context.Context
34+}
35+
36+func (s *service) Create(title string) (Session, error) {
37+ dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
38+ ID: uuid.New().String(),
39+ Title: title,
40+ })
41+ if err != nil {
42+ return Session{}, err
43+ }
44+ session := s.fromDBItem(dbSession)
45+ s.Publish(pubsub.CreatedEvent, session)
46+ return session, nil
47+}
48+
49+func (s *service) Delete(id string) error {
50+ session, err := s.Get(id)
51+ if err != nil {
52+ return err
53+ }
54+ err = s.q.DeleteSession(s.ctx, session.ID)
55+ if err != nil {
56+ return err
57+ }
58+ s.Publish(pubsub.DeletedEvent, session)
59+ return nil
60+}
61+
62+func (s *service) Get(id string) (Session, error) {
63+ dbSession, err := s.q.GetSessionByID(s.ctx, id)
64+ if err != nil {
65+ return Session{}, err
66+ }
67+ return s.fromDBItem(dbSession), nil
68+}
69+
70+func (s *service) Save(session Session) (Session, error) {
71+ dbSession, err := s.q.UpdateSession(s.ctx, db.UpdateSessionParams{
72+ ID: session.ID,
73+ Title: session.Title,
74+ Tokens: session.Tokens,
75+ Cost: session.Cost,
76+ })
77+ if err != nil {
78+ return Session{}, err
79+ }
80+ session = s.fromDBItem(dbSession)
81+ s.Publish(pubsub.UpdatedEvent, session)
82+ return session, nil
83+}
84+
85+func (s *service) List() ([]Session, error) {
86+ dbSessions, err := s.q.ListSessions(s.ctx)
87+ if err != nil {
88+ return nil, err
89+ }
90+ sessions := make([]Session, len(dbSessions))
91+ for i, dbSession := range dbSessions {
92+ sessions[i] = s.fromDBItem(dbSession)
93+ }
94+ return sessions, nil
95+}
96+
97+func (s service) fromDBItem(item db.Session) Session {
98+ return Session{
99+ ID: item.ID,
100+ Title: item.Title,
101+ MessageCount: item.MessageCount,
102+ Tokens: item.Tokens,
103+ Cost: item.Cost,
104+ CreatedAt: item.CreatedAt,
105+ UpdatedAt: item.UpdatedAt,
106+ }
107+}
108+
109+func NewService(ctx context.Context, q db.Querier) Service {
110+ broker := pubsub.NewBroker[Session]()
111+ return &service{
112+ broker,
113+ q,
114+ ctx,
115+ }
116+}
internal/tui/components/core/dialog.gonew file+84
@@ -0,0 +1,84 @@
1+package core
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
8+ "github.com/kujtimiihoxha/termai/internal/tui/util"
9+)
10+
11+type SizeableModel interface {
12+ tea.Model
13+ layout.Sizeable
14+}
15+
16+type DialogMsg struct {
17+ Content SizeableModel
18+}
19+
20+type DialogCloseMsg struct{}
21+
22+type KeyBindings struct {
23+ Return key.Binding
24+}
25+
26+var keys = KeyBindings{
27+ Return: key.NewBinding(
28+ key.WithKeys("esc"),
29+ key.WithHelp("esc", "close"),
30+ ),
31+}
32+
33+type DialogCmp interface {
34+ tea.Model
35+ layout.Bindings
36+}
37+
38+type dialogCmp struct {
39+ content SizeableModel
40+}
41+
42+func (d *dialogCmp) Init() tea.Cmd {
43+ return nil
44+}
45+
46+func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47+ switch msg := msg.(type) {
48+ case DialogMsg:
49+ d.content = msg.Content
50+ case DialogCloseMsg:
51+ d.content = nil
52+ return d, nil
53+ case tea.KeyMsg:
54+ if key.Matches(msg, keys.Return) {
55+ return d, util.CmdHandler(DialogCloseMsg{})
56+ }
57+ }
58+ if d.content != nil {
59+ u, cmd := d.content.Update(msg)
60+ d.content = u.(SizeableModel)
61+ return d, cmd
62+ }
63+ return d, nil
64+}
65+
66+func (d *dialogCmp) BindingKeys() []key.Binding {
67+ bindings := []key.Binding{keys.Return}
68+ if d.content == nil {
69+ return bindings
70+ }
71+ if c, ok := d.content.(layout.Bindings); ok {
72+ return append(bindings, c.BindingKeys()...)
73+ }
74+ return bindings
75+}
76+
77+func (d *dialogCmp) View() string {
78+ w, h := d.content.GetSize()
79+ return lipgloss.NewStyle().Width(w).Height(h).Render(d.content.View())
80+}
81+
82+func NewDialogCmp() DialogCmp {
83+ return &dialogCmp{}
84+}
internal/tui/components/core/help.go+11−11
@@ -24,23 +24,23 @@ type helpCmp struct {
2424 bindings []key.Binding
2525}
2626
27−func (m *helpCmp) Init() tea.Cmd {
27+func (h *helpCmp) Init() tea.Cmd {
2828 return nil
2929}
3030
31−func (m *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
31+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3232 switch msg := msg.(type) {
3333 case tea.WindowSizeMsg:
34− m.width = msg.Width
34+ h.width = msg.Width
3535 }
36− return m, nil
36+ return h, nil
3737}
3838
39−func (m *helpCmp) View() string {
39+func (h *helpCmp) View() string {
4040 helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
4141 helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
4242 // Compile list of bindings to render
43− bindings := removeDuplicateBindings(m.bindings)
43+ bindings := removeDuplicateBindings(h.bindings)
4444 // Enumerate through each group of bindings, populating a series of
4545 // pairs of columns, one for keys, one for descriptions
4646 var (
@@ -72,7 +72,7 @@ func (m *helpCmp) View() string {
7272 // check whether it exceeds the maximum width avail (the width of the
7373 // terminal, subtracting 2 for the borders).
7474 width += lipgloss.Width(pair)
75− if width > m.width-2 {
75+ if width > h.width-2 {
7676 break
7777 }
7878 pairs = append(pairs, pair)
@@ -80,7 +80,7 @@ func (m *helpCmp) View() string {
8080
8181 // Join pairs of columns and enclose in a border
8282 content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
83− return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(m.width - 2).Render(content)
83+ return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
8484}
8585
8686func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
@@ -103,11 +103,11 @@ func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
103103 return result
104104}
105105
106−func (m *helpCmp) SetBindings(bindings []key.Binding) {
107− m.bindings = bindings
106+func (h *helpCmp) SetBindings(bindings []key.Binding) {
107+ h.bindings = bindings
108108}
109109
110−func (m helpCmp) Height() int {
110+func (h helpCmp) Height() int {
111111 return helpWidgetHeight
112112}
113113
internal/tui/components/dialog/quit.gonew file+111
@@ -0,0 +1,111 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/tui/components/core"
8+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
9+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
10+ "github.com/kujtimiihoxha/termai/internal/tui/util"
11+
12+ "github.com/charmbracelet/huh"
13+)
14+
15+const question = "Are you sure you want to quit?"
16+
17+var (
18+ width = lipgloss.Width(question) + 6
19+ height = 3
20+)
21+
22+type QuitDialog interface {
23+ tea.Model
24+ layout.Sizeable
25+ layout.Bindings
26+}
27+
28+type quitDialogCmp struct {
29+ form *huh.Form
30+ width int
31+ height int
32+}
33+
34+func (q *quitDialogCmp) Init() tea.Cmd {
35+ return nil
36+}
37+
38+func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
39+ var cmds []tea.Cmd
40+
41+ // Process the form
42+ form, cmd := q.form.Update(msg)
43+ if f, ok := form.(*huh.Form); ok {
44+ q.form = f
45+ cmds = append(cmds, cmd)
46+ }
47+
48+ if q.form.State == huh.StateCompleted {
49+ v := q.form.GetBool("quit")
50+ if v {
51+ return q, tea.Quit
52+ }
53+ cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
54+ }
55+
56+ return q, tea.Batch(cmds...)
57+}
58+
59+func (q *quitDialogCmp) View() string {
60+ return q.form.View()
61+}
62+
63+func (q *quitDialogCmp) GetSize() (int, int) {
64+ return q.width, q.height
65+}
66+
67+func (q *quitDialogCmp) SetSize(width int, height int) {
68+ q.width = width
69+ q.height = height
70+}
71+
72+func (q *quitDialogCmp) BindingKeys() []key.Binding {
73+ return q.form.KeyBinds()
74+}
75+
76+func newQuitDialogCmp() QuitDialog {
77+ confirm := huh.NewConfirm().
78+ Title(question).
79+ Affirmative("Yes!").
80+ Key("quit").
81+ Negative("No.")
82+
83+ theme := styles.HuhTheme()
84+ theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
85+ theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
86+ form := huh.NewForm(huh.NewGroup(confirm)).
87+ WithWidth(width).
88+ WithHeight(height).
89+ WithShowHelp(false).
90+ WithTheme(theme).
91+ WithShowErrors(false)
92+ confirm.Focus()
93+ return &quitDialogCmp{
94+ form: form,
95+ width: width,
96+ }
97+}
98+
99+func NewQuitDialogCmd() tea.Cmd {
100+ content := layout.NewSinglePane(
101+ newQuitDialogCmp().(*quitDialogCmp),
102+ layout.WithSignlePaneSize(width+2, height+2),
103+ layout.WithSinglePaneBordered(true),
104+ layout.WithSinglePaneFocusable(true),
105+ layout.WithSinglePaneActiveColor(styles.Warning),
106+ )
107+ content.Focus()
108+ return util.CmdHandler(core.DialogMsg{
109+ Content: content,
110+ })
111+}
internal/tui/components/repl/editor.go+118−9
@@ -1,21 +1,130 @@
11package repl
22
3−import tea "github.com/charmbracelet/bubbletea"
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/kujtimiihoxha/termai/internal/app"
7+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
8+ "github.com/kujtimiihoxha/vimtea"
9+)
410
5−type editorCmp struct{}
11+type EditorCmp interface {
12+ tea.Model
13+ layout.Focusable
14+ layout.Sizeable
15+ layout.Bordered
16+}
17+
18+type editorCmp struct {
19+ app *app.App
20+ editor vimtea.Editor
21+ editorMode vimtea.EditorMode
22+ sessionID string
23+ focused bool
24+ width int
25+ height int
26+}
27+
28+type localKeyMap struct {
29+ SendMessage key.Binding
30+ SendMessageI key.Binding
31+}
32+
33+var keyMap = localKeyMap{
34+ SendMessage: key.NewBinding(
35+ key.WithKeys("enter"),
36+ key.WithHelp("enter", "send message normal mode"),
37+ ),
38+ SendMessageI: key.NewBinding(
39+ key.WithKeys("ctrl+s"),
40+ key.WithHelp("ctrl+s", "send message insert mode"),
41+ ),
42+}
43+
44+func (m *editorCmp) Init() tea.Cmd {
45+ return m.editor.Init()
46+}
47+
48+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
49+ switch msg := msg.(type) {
50+ case vimtea.EditorModeMsg:
51+ m.editorMode = msg.Mode
52+ case SelectedSessionMsg:
53+ if msg.SessionID != m.sessionID {
54+ m.sessionID = msg.SessionID
55+ }
56+ }
57+ if m.IsFocused() {
58+ switch msg := msg.(type) {
59+ case tea.KeyMsg:
60+ switch {
61+ case key.Matches(msg, keyMap.SendMessage):
62+ if m.editorMode == vimtea.ModeNormal {
63+ return m, m.Send()
64+ }
65+ case key.Matches(msg, keyMap.SendMessageI):
66+ if m.editorMode == vimtea.ModeInsert {
67+ return m, m.Send()
68+ }
69+ }
70+ }
71+ u, cmd := m.editor.Update(msg)
72+ m.editor = u.(vimtea.Editor)
73+ return m, cmd
74+ }
75+ return m, nil
76+}
677
7−func (i *editorCmp) Init() tea.Cmd {
78+// Blur implements EditorCmp.
79+func (m *editorCmp) Blur() tea.Cmd {
80+ m.focused = false
881 return nil
982}
1083
11−func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
12− return i, nil
84+// BorderText implements EditorCmp.
85+func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
86+ return map[layout.BorderPosition]string{
87+ layout.TopLeftBorder: "New Message",
88+ }
89+}
90+
91+// Focus implements EditorCmp.
92+func (m *editorCmp) Focus() tea.Cmd {
93+ m.focused = true
94+ return m.editor.Tick()
95+}
96+
97+// GetSize implements EditorCmp.
98+func (m *editorCmp) GetSize() (int, int) {
99+ return m.width, m.height
100+}
101+
102+// IsFocused implements EditorCmp.
103+func (m *editorCmp) IsFocused() bool {
104+ return m.focused
105+}
106+
107+// SetSize implements EditorCmp.
108+func (m *editorCmp) SetSize(width int, height int) {
109+ m.width = width
110+ m.height = height
111+ m.editor.SetSize(width, height)
112+}
113+
114+func (m *editorCmp) Send() tea.Cmd {
115+ return func() tea.Msg {
116+ // TODO: Send message
117+ return nil
118+ }
13119}
14120
15−func (i *editorCmp) View() string {
16− return "Editor"
121+func (m *editorCmp) View() string {
122+ return m.editor.View()
17123}
18124
19−func NewEditorCmp() tea.Model {
20− return &editorCmp{}
125+func NewEditorCmp(app *app.App) EditorCmp {
126+ return &editorCmp{
127+ app: app,
128+ editor: vimtea.NewEditor(),
129+ }
21130}
internal/tui/components/repl/messages.go+11−4
@@ -1,8 +1,13 @@
11package repl
22
3−import tea "github.com/charmbracelet/bubbletea"
3+import (
4+ tea "github.com/charmbracelet/bubbletea"
5+ "github.com/kujtimiihoxha/termai/internal/app"
6+)
47
5−type messagesCmp struct{}
8+type messagesCmp struct {
9+ app *app.App
10+}
611
712func (i *messagesCmp) Init() tea.Cmd {
813 return nil
@@ -16,6 +21,8 @@ func (i *messagesCmp) View() string {
1621 return "Messages"
1722}
1823
19−func NewMessagesCmp() tea.Model {
20− return &messagesCmp{}
24+func NewMessagesCmp(app *app.App) tea.Model {
25+ return &messagesCmp{
26+ app,
27+ }
2128}
internal/tui/components/repl/sessions.gonew file+161
@@ -0,0 +1,161 @@
1+package repl
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ "github.com/charmbracelet/bubbles/list"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/kujtimiihoxha/termai/internal/app"
10+ "github.com/kujtimiihoxha/termai/internal/session"
11+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
12+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
13+ "github.com/kujtimiihoxha/termai/internal/tui/util"
14+)
15+
16+type SessionsCmp interface {
17+ tea.Model
18+ layout.Sizeable
19+ layout.Focusable
20+ layout.Bordered
21+ layout.Bindings
22+}
23+type sessionsCmp struct {
24+ app *app.App
25+ list list.Model
26+ focused bool
27+}
28+
29+type listItem struct {
30+ id, title, desc string
31+}
32+
33+func (i listItem) Title() string { return i.title }
34+func (i listItem) Description() string { return i.desc }
35+func (i listItem) FilterValue() string { return i.title }
36+
37+type InsertSessionsMsg struct {
38+ sessions []session.Session
39+}
40+
41+type SelectedSessionMsg struct {
42+ SessionID string
43+}
44+
45+func (i *sessionsCmp) Init() tea.Cmd {
46+ existing, err := i.app.Sessions.List()
47+ if err != nil {
48+ return util.ReportError(err)
49+ }
50+ if len(existing) == 0 || existing[0].MessageCount > 0 {
51+ session, err := i.app.Sessions.Create(
52+ "New Session",
53+ )
54+ if err != nil {
55+ return util.ReportError(err)
56+ }
57+ existing = append(existing, session)
58+ }
59+ return tea.Batch(
60+ util.CmdHandler(InsertSessionsMsg{existing}),
61+ util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
62+ )
63+}
64+
65+func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66+ switch msg := msg.(type) {
67+ case InsertSessionsMsg:
68+ items := make([]list.Item, len(msg.sessions))
69+ for i, s := range msg.sessions {
70+ items[i] = listItem{
71+ id: s.ID,
72+ title: s.Title,
73+ desc: fmt.Sprintf("Tokens: %d, Cost: %.2f", s.Tokens, s.Cost),
74+ }
75+ }
76+ return i, i.list.SetItems(items)
77+ }
78+ if i.focused {
79+ u, cmd := i.list.Update(msg)
80+ i.list = u
81+ return i, cmd
82+ }
83+ return i, nil
84+}
85+
86+func (i *sessionsCmp) View() string {
87+ return i.list.View()
88+}
89+
90+func (i *sessionsCmp) Blur() tea.Cmd {
91+ i.focused = false
92+ return nil
93+}
94+
95+func (i *sessionsCmp) Focus() tea.Cmd {
96+ i.focused = true
97+ return nil
98+}
99+
100+func (i *sessionsCmp) GetSize() (int, int) {
101+ return i.list.Width(), i.list.Height()
102+}
103+
104+func (i *sessionsCmp) IsFocused() bool {
105+ return i.focused
106+}
107+
108+func (i *sessionsCmp) SetSize(width int, height int) {
109+ i.list.SetSize(width, height)
110+}
111+
112+func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
113+ totalCount := len(i.list.Items())
114+ itemsPerPage := i.list.Paginator.PerPage
115+ currentPage := i.list.Paginator.Page
116+
117+ current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
118+
119+ pageInfo := fmt.Sprintf(
120+ "%d-%d of %d",
121+ currentPage*itemsPerPage+1,
122+ current,
123+ totalCount,
124+ )
125+ return map[layout.BorderPosition]string{
126+ layout.TopMiddleBorder: "Sessions",
127+ layout.BottomMiddleBorder: pageInfo,
128+ }
129+}
130+
131+func (i *sessionsCmp) BindingKeys() []key.Binding {
132+ return layout.KeyMapToSlice(i.list.KeyMap)
133+}
134+
135+func NewSessionsCmp(app *app.App) SessionsCmp {
136+ listDelegate := list.NewDefaultDelegate()
137+ defaultItemStyle := list.NewDefaultItemStyles()
138+ defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
139+ defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
140+
141+ defaultStyle := list.DefaultStyles()
142+ defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
143+ defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
144+
145+ listDelegate.Styles = defaultItemStyle
146+
147+ listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
148+ listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
149+ listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
150+ listComponent.SetShowTitle(false)
151+ listComponent.SetShowPagination(false)
152+ listComponent.SetShowHelp(false)
153+ listComponent.SetShowStatusBar(false)
154+ listComponent.DisableQuitKeybindings()
155+
156+ return &sessionsCmp{
157+ app: app,
158+ list: listComponent,
159+ focused: false,
160+ }
161+}
internal/tui/components/repl/threads.godeleted−21
@@ -1,21 +0,0 @@
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.go+15−5
@@ -73,7 +73,14 @@ func (b *bentoLayout) GetSize() (int, int) {
7373}
7474
7575func (b *bentoLayout) Init() tea.Cmd {
76− return nil
76+ var cmds []tea.Cmd
77+ for _, pane := range b.panes {
78+ cmd := pane.Init()
79+ if cmd != nil {
80+ cmds = append(cmds, cmd)
81+ }
82+ }
83+ return tea.Batch(cmds...)
7784}
7885
7986func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -98,12 +105,15 @@ func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98105 }
99106 }
100107
101− if pane, ok := b.panes[b.currentPane]; ok {
108+ var cmds []tea.Cmd
109+ for id, pane := range b.panes {
102110 u, cmd := pane.Update(msg)
103− b.panes[b.currentPane] = u.(SinglePaneLayout)
104− return b, cmd
111+ b.panes[id] = u.(SinglePaneLayout)
112+ if cmd != nil {
113+ cmds = append(cmds, cmd)
114+ }
105115 }
106− return b, nil
116+ return b, tea.Batch(cmds...)
107117}
108118
109119func (b *bentoLayout) View() string {
internal/tui/layout/border.go+5−2
@@ -24,17 +24,20 @@ var (
2424 InactivePreviewBorder = styles.Grey
2525)
2626
27−func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
27+func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
2828 if embeddedText == nil {
2929 embeddedText = make(map[BorderPosition]string)
3030 }
31+ if activeColor == nil {
32+ activeColor = ActiveBorder
33+ }
3134 var (
3235 thickness = map[bool]lipgloss.Border{
3336 true: lipgloss.Border(lipgloss.ThickBorder()),
3437 false: lipgloss.Border(lipgloss.NormalBorder()),
3538 }
3639 color = map[bool]lipgloss.TerminalColor{
37− true: ActiveBorder,
40+ true: activeColor,
3841 false: InactivePreviewBorder,
3942 }
4043 border = thickness[active]
internal/tui/layout/overlay.gonew file+205
@@ -0,0 +1,205 @@
1+package layout
2+
3+import (
4+ "bytes"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss"
8+ "github.com/kujtimiihoxha/termai/internal/tui/util"
9+ "github.com/mattn/go-runewidth"
10+ "github.com/muesli/ansi"
11+ "github.com/muesli/reflow/truncate"
12+ "github.com/muesli/termenv"
13+)
14+
15+// Most of this code is borrowed from
16+// https://github.com/charmbracelet/lipgloss/pull/102
17+// as well as the lipgloss library, with some modification for what I needed.
18+
19+// Split a string into lines, additionally returning the size of the widest
20+// line.
21+func getLines(s string) (lines []string, widest int) {
22+ lines = strings.Split(s, "\n")
23+
24+ for _, l := range lines {
25+ w := ansi.PrintableRuneWidth(l)
26+ if widest < w {
27+ widest = w
28+ }
29+ }
30+
31+ return lines, widest
32+}
33+
34+// PlaceOverlay places fg on top of bg.
35+func PlaceOverlay(
36+ x, y int,
37+ fg, bg string,
38+ shadow bool, opts ...WhitespaceOption,
39+) string {
40+ fgLines, fgWidth := getLines(fg)
41+ bgLines, bgWidth := getLines(bg)
42+ bgHeight := len(bgLines)
43+ fgHeight := len(fgLines)
44+
45+ if shadow {
46+ var shadowbg string = ""
47+ shadowchar := lipgloss.NewStyle().
48+ Foreground(lipgloss.Color("#333333")).
49+ Render("░")
50+ for i := 0; i <= fgHeight; i++ {
51+ if i == 0 {
52+ shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
53+ } else {
54+ shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
55+ }
56+ }
57+
58+ fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
59+ fgLines, fgWidth = getLines(fg)
60+ fgHeight = len(fgLines)
61+ }
62+
63+ if fgWidth >= bgWidth && fgHeight >= bgHeight {
64+ // FIXME: return fg or bg?
65+ return fg
66+ }
67+ // TODO: allow placement outside of the bg box?
68+ x = util.Clamp(x, 0, bgWidth-fgWidth)
69+ y = util.Clamp(y, 0, bgHeight-fgHeight)
70+
71+ ws := &whitespace{}
72+ for _, opt := range opts {
73+ opt(ws)
74+ }
75+
76+ var b strings.Builder
77+ for i, bgLine := range bgLines {
78+ if i > 0 {
79+ b.WriteByte('\n')
80+ }
81+ if i < y || i >= y+fgHeight {
82+ b.WriteString(bgLine)
83+ continue
84+ }
85+
86+ pos := 0
87+ if x > 0 {
88+ left := truncate.String(bgLine, uint(x))
89+ pos = ansi.PrintableRuneWidth(left)
90+ b.WriteString(left)
91+ if pos < x {
92+ b.WriteString(ws.render(x - pos))
93+ pos = x
94+ }
95+ }
96+
97+ fgLine := fgLines[i-y]
98+ b.WriteString(fgLine)
99+ pos += ansi.PrintableRuneWidth(fgLine)
100+
101+ right := cutLeft(bgLine, pos)
102+ bgWidth := ansi.PrintableRuneWidth(bgLine)
103+ rightWidth := ansi.PrintableRuneWidth(right)
104+ if rightWidth <= bgWidth-pos {
105+ b.WriteString(ws.render(bgWidth - rightWidth - pos))
106+ }
107+
108+ b.WriteString(right)
109+ }
110+
111+ return b.String()
112+}
113+
114+// cutLeft cuts printable characters from the left.
115+// This function is heavily based on muesli's ansi and truncate packages.
116+func cutLeft(s string, cutWidth int) string {
117+ var (
118+ pos int
119+ isAnsi bool
120+ ab bytes.Buffer
121+ b bytes.Buffer
122+ )
123+ for _, c := range s {
124+ var w int
125+ if c == ansi.Marker || isAnsi {
126+ isAnsi = true
127+ ab.WriteRune(c)
128+ if ansi.IsTerminator(c) {
129+ isAnsi = false
130+ if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) {
131+ ab.Reset()
132+ }
133+ }
134+ } else {
135+ w = runewidth.RuneWidth(c)
136+ }
137+
138+ if pos >= cutWidth {
139+ if b.Len() == 0 {
140+ if ab.Len() > 0 {
141+ b.Write(ab.Bytes())
142+ }
143+ if pos-cutWidth > 1 {
144+ b.WriteByte(' ')
145+ continue
146+ }
147+ }
148+ b.WriteRune(c)
149+ }
150+ pos += w
151+ }
152+ return b.String()
153+}
154+
155+func max(a, b int) int {
156+ if a > b {
157+ return a
158+ }
159+ return b
160+}
161+
162+func min(a, b int) int {
163+ if a < b {
164+ return a
165+ }
166+ return b
167+}
168+
169+type whitespace struct {
170+ style termenv.Style
171+ chars string
172+}
173+
174+// Render whitespaces.
175+func (w whitespace) render(width int) string {
176+ if w.chars == "" {
177+ w.chars = " "
178+ }
179+
180+ r := []rune(w.chars)
181+ j := 0
182+ b := strings.Builder{}
183+
184+ // Cycle through runes and print them into the whitespace.
185+ for i := 0; i < width; {
186+ b.WriteRune(r[j])
187+ j++
188+ if j >= len(r) {
189+ j = 0
190+ }
191+ i += ansi.PrintableRuneWidth(string(r[j]))
192+ }
193+
194+ // Fill any extra gaps white spaces. This might be necessary if any runes
195+ // are more than one cell wide, which could leave a one-rune gap.
196+ short := width - ansi.PrintableRuneWidth(b.String())
197+ if short > 0 {
198+ b.WriteString(strings.Repeat(" ", short))
199+ }
200+
201+ return w.style.Styled(b.String())
202+}
203+
204+// WhitespaceOption sets a styling rule for rendering whitespace.
205+type WhitespaceOption func(*whitespace)
internal/tui/layout/single.go+18−5
@@ -11,6 +11,7 @@ type SinglePaneLayout interface {
1111 Focusable
1212 Sizeable
1313 Bindings
14+ Pane() tea.Model
1415}
1516
1617type singlePaneLayout struct {
@@ -26,6 +27,8 @@ type singlePaneLayout struct {
2627 content tea.Model
2728
2829 padding []int
30+
31+ activeColor lipgloss.TerminalColor
2932}
3033
3134type SinglePaneOption func(*singlePaneLayout)
@@ -48,7 +51,7 @@ func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4851func (s *singlePaneLayout) View() string {
4952 style := lipgloss.NewStyle().Width(s.width).Height(s.height)
5053 if s.bordered {
51− style = style.Width(s.width).Height(s.height)
54+ style = style.Width(s.width - 2).Height(s.height - 2)
5255 }
5356 if s.padding != nil {
5457 style = style.Padding(s.padding...)
@@ -61,7 +64,7 @@ func (s *singlePaneLayout) View() string {
6164 if bordered, ok := s.content.(Bordered); ok {
6265 s.borderText = bordered.BorderText()
6366 }
64− return Borderize(content, s.focused, s.borderText)
67+ return Borderize(content, s.focused, s.borderText, s.activeColor)
6568 }
6669 return content
6770}
@@ -89,11 +92,11 @@ func (s *singlePaneLayout) Focus() tea.Cmd {
8992func (s *singlePaneLayout) SetSize(width, height int) {
9093 s.width = width
9194 s.height = height
95+ childWidth, childHeight := s.width, s.height
9296 if s.bordered {
93− s.width -= 2
94− s.height -= 2
97+ childWidth -= 2
98+ childHeight -= 2
9599 }
96− childWidth, childHeight := s.width, s.height
97100 if s.padding != nil {
98101 if len(s.padding) == 1 {
99102 childWidth -= s.padding[0] * 2
@@ -131,6 +134,10 @@ func (s *singlePaneLayout) BindingKeys() []key.Binding {
131134 return []key.Binding{}
132135}
133136
137+func (s *singlePaneLayout) Pane() tea.Model {
138+ return s.content
139+}
140+
134141func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
135142 layout := &singlePaneLayout{
136143 content: content,
@@ -171,3 +178,9 @@ func WithSinglePanePadding(padding ...int) SinglePaneOption {
171178 opts.padding = padding
172179 }
173180}
181+
182+func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
183+ return func(opts *singlePaneLayout) {
184+ opts.activeColor = color
185+ }
186+}
internal/tui/page/repl.go+5−4
@@ -2,18 +2,19 @@ package page
22
33import (
44 tea "github.com/charmbracelet/bubbletea"
5+ "github.com/kujtimiihoxha/termai/internal/app"
56 "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
67 "github.com/kujtimiihoxha/termai/internal/tui/layout"
78)
89
910var ReplPage PageID = "repl"
1011
11−func NewReplPage() tea.Model {
12+func NewReplPage(app *app.App) tea.Model {
1213 return layout.NewBentoLayout(
1314 layout.BentoPanes{
14− layout.BentoLeftPane: repl.NewThreadsCmp(),
15− layout.BentoRightTopPane: repl.NewMessagesCmp(),
16− layout.BentoRightBottomPane: repl.NewEditorCmp(),
15+ layout.BentoLeftPane: repl.NewSessionsCmp(app),
16+ layout.BentoRightTopPane: repl.NewMessagesCmp(app),
17+ layout.BentoRightBottomPane: repl.NewEditorCmp(app),
1718 },
1819 )
1920}
internal/tui/styles/huh.gonew file+46
@@ -0,0 +1,46 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/huh"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+func HuhTheme() *huh.Theme {
9+ t := huh.ThemeBase()
10+
11+ t.Focused.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
12+ t.Focused.Title = t.Focused.Title.Foreground(Text)
13+ t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(Text)
14+ t.Focused.Directory = t.Focused.Directory.Foreground(Text)
15+ t.Focused.Description = t.Focused.Description.Foreground(SubText0)
16+ t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(Red)
17+ t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(Red)
18+ t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(Blue)
19+ t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(Blue)
20+ t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(Blue)
21+ t.Focused.Option = t.Focused.Option.Foreground(Text)
22+ t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Blue)
23+ t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(Green)
24+ t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(Green)
25+ t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(Text)
26+ t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(Text)
27+ t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(Base).Background(Blue)
28+ t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(Text).Background(Base)
29+
30+ t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(Teal)
31+ t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(Overlay0)
32+ t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(Blue)
33+
34+ t.Blurred = t.Focused
35+ t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
36+
37+ t.Help.Ellipsis = t.Help.Ellipsis.Foreground(SubText0)
38+ t.Help.ShortKey = t.Help.ShortKey.Foreground(SubText0)
39+ t.Help.ShortDesc = t.Help.ShortDesc.Foreground(Ovelay1)
40+ t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(SubText0)
41+ t.Help.FullKey = t.Help.FullKey.Foreground(SubText0)
42+ t.Help.FullDesc = t.Help.FullDesc.Foreground(Ovelay1)
43+ t.Help.FullSeparator = t.Help.FullSeparator.Foreground(SubText0)
44+
45+ return t
46+}
internal/tui/styles/styles.go+3
@@ -122,4 +122,7 @@ var (
122122
123123 Primary = Blue
124124 Secondary = Mauve
125+
126+ Warning = Peach
127+ Error = Red
125128)
internal/tui/tui.go+62−18
@@ -4,10 +4,13 @@ import (
44 "github.com/charmbracelet/bubbles/key"
55 tea "github.com/charmbracelet/bubbletea"
66 "github.com/charmbracelet/lipgloss"
7+ "github.com/kujtimiihoxha/termai/internal/app"
78 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
9+ "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
810 "github.com/kujtimiihoxha/termai/internal/tui/layout"
911 "github.com/kujtimiihoxha/termai/internal/tui/page"
1012 "github.com/kujtimiihoxha/termai/internal/tui/util"
13+ "github.com/kujtimiihoxha/vimtea"
1114)
1215
1316type keyMap struct {
@@ -49,6 +52,9 @@ type appModel struct {
4952 loadedPages map[page.PageID]bool
5053 status tea.Model
5154 help core.HelpCmp
55+ dialog core.DialogCmp
56+ dialogVisible bool
57+ editorMode vimtea.EditorMode
5258 showHelp bool
5359}
5460
@@ -60,6 +66,8 @@ func (a appModel) Init() tea.Cmd {
6066
6167func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6268 switch msg := msg.(type) {
69+ case vimtea.EditorModeMsg:
70+ a.editorMode = msg.Mode
6371 case tea.WindowSizeMsg:
6472 msg.Height -= 1 // Make space for the status bar
6573 a.width, a.height = msg.Width, msg.Height
@@ -72,31 +80,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7280 p, cmd := a.pages[a.currentPage].Update(msg)
7381 a.pages[a.currentPage] = p
7482 return a, cmd
83+ case core.DialogMsg:
84+ d, cmd := a.dialog.Update(msg)
85+ a.dialog = d.(core.DialogCmp)
86+ a.dialogVisible = true
87+ return a, cmd
88+ case core.DialogCloseMsg:
89+ d, cmd := a.dialog.Update(msg)
90+ a.dialog = d.(core.DialogCmp)
91+ a.dialogVisible = false
92+ return a, cmd
7593 case util.InfoMsg:
7694 a.status, _ = a.status.Update(msg)
7795 case util.ErrorMsg:
7896 a.status, _ = a.status.Update(msg)
7997 case tea.KeyMsg:
80− switch {
81− case key.Matches(msg, keys.Quit):
82− return a, tea.Quit
83− case key.Matches(msg, keys.Back):
84− if a.previousPage != "" {
85− return a, a.moveToPage(a.previousPage)
86− }
87− case key.Matches(msg, keys.Return):
88− if a.showHelp {
98+ if a.editorMode == vimtea.ModeNormal {
99+ switch {
100+ case key.Matches(msg, keys.Quit):
101+ return a, dialog.NewQuitDialogCmd()
102+ case key.Matches(msg, keys.Back):
103+ if a.previousPage != "" {
104+ return a, a.moveToPage(a.previousPage)
105+ }
106+ case key.Matches(msg, keys.Return):
107+ if a.showHelp {
108+ a.ToggleHelp()
109+ return a, nil
110+ }
111+ case key.Matches(msg, keys.Logs):
112+ return a, a.moveToPage(page.LogsPage)
113+ case key.Matches(msg, keys.Help):
89114 a.ToggleHelp()
90115 return a, nil
91116 }
92− return a, nil
93− case key.Matches(msg, keys.Logs):
94− return a, a.moveToPage(page.LogsPage)
95− case key.Matches(msg, keys.Help):
96− a.ToggleHelp()
97− return a, nil
98117 }
99118 }
119+ if a.dialogVisible {
120+ d, cmd := a.dialog.Update(msg)
121+ a.dialog = d.(core.DialogCmp)
122+ return a, cmd
123+ }
100124 p, cmd := a.pages[a.currentPage].Update(msg)
101125 a.pages[a.currentPage] = p
102126 return a, cmd
@@ -141,25 +165,45 @@ func (a appModel) View() string {
141165 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
142166 bindings = append(bindings, p.BindingKeys()...)
143167 }
168+ if a.dialogVisible {
169+ bindings = append(bindings, a.dialog.BindingKeys()...)
170+ }
144171 a.help.SetBindings(bindings)
145172 components = append(components, a.help.View())
146173 }
147174
148175 components = append(components, a.status.View())
149176
150− return lipgloss.JoinVertical(lipgloss.Top, components...)
177+ appView := lipgloss.JoinVertical(lipgloss.Top, components...)
178+
179+ if a.dialogVisible {
180+ overlay := a.dialog.View()
181+ row := lipgloss.Height(appView) / 2
182+ row -= lipgloss.Height(overlay) / 2
183+ col := lipgloss.Width(appView) / 2
184+ col -= lipgloss.Width(overlay) / 2
185+ appView = layout.PlaceOverlay(
186+ col,
187+ row,
188+ overlay,
189+ appView,
190+ true,
191+ )
192+ }
193+ return appView
151194}
152195
153−func New() tea.Model {
196+func New(app *app.App) tea.Model {
154197 return &appModel{
155198 currentPage: page.ReplPage,
156199 loadedPages: make(map[page.PageID]bool),
157200 status: core.NewStatusCmp(),
158201 help: core.NewHelpCmp(),
202+ dialog: core.NewDialogCmp(),
159203 pages: map[page.PageID]tea.Model{
160204 page.LogsPage: page.NewLogsPage(),
161205 page.InitPage: page.NewInitPage(),
162− page.ReplPage: page.NewReplPage(),
206+ page.ReplPage: page.NewReplPage(app),
163207 },
164208 }
165209}
internal/tui/util/util.go+7
@@ -16,3 +16,10 @@ type (
1616 InfoMsg string
1717 ErrorMsg error
1818)
19+
20+func Clamp(v, low, high int) int {
21+ if high < low {
22+ low, high = high, low
23+ }
24+ return min(high, max(low, v))
25+}
main.gonew file+11
@@ -0,0 +1,11 @@
1+/*
2+Copyright © 2025 NAME HERE <EMAIL ADDRESS>
3+
4+*/
5+package main
6+
7+import "github.com/kujtimiihoxha/termai/cmd"
8+
9+func main() {
10+ cmd.Execute()
11+}
sqlc.yamlnew file+14
@@ -0,0 +1,14 @@
1+version: "2"
2+sql:
3+ - engine: "sqlite"
4+ schema: "internal/db/migrations"
5+ queries: "internal/db/sql"
6+ gen:
7+ go:
8+ package: "db"
9+ out: "internal/db"
10+ emit_json_tags: true
11+ emit_prepared_queries: true
12+ emit_interface: true
13+ emit_exact_table_names: false
14+ emit_empty_slices: true