mirror of
https://github.com/sst/opencode.git
synced 2025-07-07 16:14:59 +00:00
add initial stuff
This commit is contained in:
parent
796bbf4d66
commit
8daa6e774a
36 changed files with 1779 additions and 143 deletions
2
.sqlfluff
Normal file
2
.sqlfluff
Normal file
|
@ -0,0 +1,2 @@
|
|||
[sqlfluff:rules]
|
||||
exclude_rules = AM04
|
0
LICENSE
Normal file
0
LICENSE
Normal file
126
cmd/root.go
Normal file
126
cmd/root.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/db"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "termai",
|
||||
Short: "A terminal ai assistant",
|
||||
Long: `A terminal ai assistant`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flag("help").Changed {
|
||||
cmd.Help()
|
||||
return nil
|
||||
}
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
viper.Set("debug", debug)
|
||||
if debug {
|
||||
viper.Set("log.level", "debug")
|
||||
}
|
||||
|
||||
conn, err := db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
app := app.New(ctx, conn)
|
||||
app.Logger.Info("Starting termai...")
|
||||
tui := tea.NewProgram(
|
||||
tui.New(app),
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
app.Logger.Info("Setting up subscriptions...")
|
||||
ch, unsub := setupSubscriptions(app)
|
||||
defer unsub()
|
||||
|
||||
go func() {
|
||||
for msg := range ch {
|
||||
tui.Send(msg)
|
||||
}
|
||||
}()
|
||||
if _, err := tui.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg)
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(app.Context)
|
||||
|
||||
if viper.GetBool("debug") {
|
||||
sub := app.Logger.Subscribe(ctx)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
for ev := range sub {
|
||||
ch <- ev
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
{
|
||||
sub := app.Sessions.Subscribe(ctx)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
for ev := range sub {
|
||||
ch <- ev
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
return ch, func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
viper.SetConfigName(".termai")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath("$HOME")
|
||||
viper.AddConfigPath("$XDG_CONFIG_HOME/termai")
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetEnvPrefix("TERMAI")
|
||||
// SET DEFAULTS
|
||||
viper.SetDefault("log.level", "info")
|
||||
viper.SetDefault("data.dir", ".termai")
|
||||
|
||||
//
|
||||
viper.ReadInConfig()
|
||||
}
|
||||
|
||||
func init() {
|
||||
loadConfig()
|
||||
// Here you will define your flags and configuration settings.
|
||||
// Cobra supports persistent flags, which, if defined here,
|
||||
// will be global for your application.
|
||||
|
||||
rootCmd.Flags().BoolP("help", "h", false, "Help")
|
||||
rootCmd.Flags().BoolP("debug", "d", false, "Help")
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/logging"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui"
|
||||
)
|
||||
|
||||
var log = logging.Get()
|
||||
|
||||
func main() {
|
||||
log.Info("Starting termai...")
|
||||
ctx := context.Background()
|
||||
|
||||
app := tea.NewProgram(
|
||||
tui.New(),
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
log.Info("Setting up subscriptions...")
|
||||
ch, unsub := setupSubscriptions(ctx)
|
||||
defer unsub()
|
||||
|
||||
go func() {
|
||||
for msg := range ch {
|
||||
app.Send(msg)
|
||||
}
|
||||
}()
|
||||
if _, err := app.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSubscriptions(ctx context.Context) (chan tea.Msg, func()) {
|
||||
ch := make(chan tea.Msg)
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
{
|
||||
sub := log.Subscribe(ctx)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
for ev := range sub {
|
||||
ch <- ev
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
return ch, func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}
|
||||
}
|
37
go.mod
37
go.mod
|
@ -7,37 +7,64 @@ require (
|
|||
github.com/charmbracelet/bubbles v0.20.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/glamour v0.9.1
|
||||
github.com/charmbracelet/huh v0.6.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/go-logfmt/logfmt v0.6.0
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mattn/go-runewidth v0.0.16
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
|
||||
github.com/muesli/reflow v0.3.0
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
89
go.sum
89
go.sum
|
@ -1,9 +1,13 @@
|
|||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
|
||||
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
|
@ -20,6 +24,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
|
|||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
|
||||
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
|
||||
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
||||
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
|
@ -28,18 +34,55 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
|
|||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840 h1:AORwYXTzap8hg0zmTA5RWB/0fxv9F19dF42dCY0IsRc=
|
||||
github.com/kujtimiihoxha/vimtea v0.0.3-0.20250317175717-9d8ba9c69840/go.mod h1:VyCD1xYnYem+OHp9nzGNx8x7rCwaeB+2VSyOUgX8Zyc=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
@ -49,8 +92,12 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
|
|||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
|
@ -59,10 +106,39 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
|
||||
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
|
@ -70,8 +146,12 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
|||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
|
@ -82,3 +162,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
31
internal/app/services.go
Normal file
31
internal/app/services.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/kujtimiihoxha/termai/internal/db"
|
||||
"github.com/kujtimiihoxha/termai/internal/logging"
|
||||
"github.com/kujtimiihoxha/termai/internal/session"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Context context.Context
|
||||
|
||||
Sessions session.Service
|
||||
|
||||
Logger logging.Interface
|
||||
}
|
||||
|
||||
func New(ctx context.Context, conn *sql.DB) *App {
|
||||
q := db.New(conn)
|
||||
log := logging.NewLogger(logging.Options{
|
||||
Level: viper.GetString("log.level"),
|
||||
})
|
||||
return &App{
|
||||
Context: ctx,
|
||||
Sessions: session.NewService(ctx, q),
|
||||
Logger: log,
|
||||
}
|
||||
}
|
93
internal/db/connect.go
Normal file
93
internal/db/connect.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kujtimiihoxha/termai/internal/logging"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var log = logging.Get()
|
||||
|
||||
func Connect() (*sql.DB, error) {
|
||||
dataDir := viper.GetString("data.dir")
|
||||
if dataDir == "" {
|
||||
return nil, fmt.Errorf("data.dir is not set")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
dbPath := filepath.Join(dataDir, "termai.db")
|
||||
// Open the SQLite database
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Verify connection
|
||||
if err = db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Set pragmas for better performance
|
||||
pragmas := []string{
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA page_size = 4096;",
|
||||
"PRAGMA cache_size = -8000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if _, err = db.Exec(pragma); err != nil {
|
||||
log.Warn("Failed to set pragma", pragma, err)
|
||||
} else {
|
||||
log.Warn("Set pragma", "pragma", pragma)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize schema from embedded file
|
||||
d, err := iofs.New(FS, "migrations")
|
||||
if err != nil {
|
||||
log.Error("Failed to open embedded migrations", "error", err)
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to open embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
||||
if err != nil {
|
||||
log.Error("Failed to create SQLite driver", "error", err)
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to create SQLite driver: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", d, "ql", driver)
|
||||
if err != nil {
|
||||
log.Error("Failed to create migration instance", "error", err)
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && err != migrate.ErrNoChange {
|
||||
log.Error("Migration failed", "error", err)
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to apply schema: %w", err)
|
||||
} else if err == migrate.ErrNoChange {
|
||||
log.Info("No schema changes to apply")
|
||||
} else {
|
||||
log.Info("Schema migration applied successfully")
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
128
internal/db/db.go
Normal file
128
internal/db/db.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
|
||||
q := Queries{db: db}
|
||||
var err error
|
||||
if q.createSessionStmt, err = db.PrepareContext(ctx, createSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query CreateSession: %w", err)
|
||||
}
|
||||
if q.deleteSessionStmt, err = db.PrepareContext(ctx, deleteSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query DeleteSession: %w", err)
|
||||
}
|
||||
if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
|
||||
}
|
||||
if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query ListSessions: %w", err)
|
||||
}
|
||||
if q.updateSessionStmt, err = db.PrepareContext(ctx, updateSession); err != nil {
|
||||
return nil, fmt.Errorf("error preparing query UpdateSession: %w", err)
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (q *Queries) Close() error {
|
||||
var err error
|
||||
if q.createSessionStmt != nil {
|
||||
if cerr := q.createSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing createSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.deleteSessionStmt != nil {
|
||||
if cerr := q.deleteSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing deleteSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.getSessionByIDStmt != nil {
|
||||
if cerr := q.getSessionByIDStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.listSessionsStmt != nil {
|
||||
if cerr := q.listSessionsStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing listSessionsStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
if q.updateSessionStmt != nil {
|
||||
if cerr := q.updateSessionStmt.Close(); cerr != nil {
|
||||
err = fmt.Errorf("error closing updateSessionStmt: %w", cerr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.ExecContext(ctx, args...)
|
||||
default:
|
||||
return q.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.QueryContext(ctx, args...)
|
||||
default:
|
||||
return q.db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row {
|
||||
switch {
|
||||
case stmt != nil && q.tx != nil:
|
||||
return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...)
|
||||
case stmt != nil:
|
||||
return stmt.QueryRowContext(ctx, args...)
|
||||
default:
|
||||
return q.db.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
tx *sql.Tx
|
||||
createSessionStmt *sql.Stmt
|
||||
deleteSessionStmt *sql.Stmt
|
||||
getSessionByIDStmt *sql.Stmt
|
||||
listSessionsStmt *sql.Stmt
|
||||
updateSessionStmt *sql.Stmt
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
tx: tx,
|
||||
createSessionStmt: q.createSessionStmt,
|
||||
deleteSessionStmt: q.deleteSessionStmt,
|
||||
getSessionByIDStmt: q.getSessionByIDStmt,
|
||||
listSessionsStmt: q.listSessionsStmt,
|
||||
updateSessionStmt: q.updateSessionStmt,
|
||||
}
|
||||
}
|
6
internal/db/embed.go
Normal file
6
internal/db/embed.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package db
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var FS embed.FS
|
4
internal/db/migrations/000001_initial.down.sql
Normal file
4
internal/db/migrations/000001_initial.down.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
-- sqlfluff:dialect:sqlite
|
||||
DROP TRIGGER IF EXISTS update_sessions_updated_at;
|
||||
|
||||
DROP TABLE IF EXISTS sessions;
|
17
internal/db/migrations/000001_initial.up.sql
Normal file
17
internal/db/migrations/000001_initial.up.sql
Normal file
|
@ -0,0 +1,17 @@
|
|||
-- sqlfluff:dialect:sqlite
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
||||
tokens INTEGER NOT NULL DEFAULT 0 CHECK (tokens >= 0),
|
||||
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
|
||||
updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
|
||||
created_at INTEGER NOT NULL -- Unix timestamp in milliseconds
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
|
||||
AFTER UPDATE ON sessions
|
||||
BEGIN
|
||||
UPDATE sessions SET updated_at = strftime('%s', 'now')
|
||||
WHERE id = new.id;
|
||||
END;
|
15
internal/db/models.go
Normal file
15
internal/db/models.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package db
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
20
internal/db/querier.go
Normal file
20
internal/db/querier.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
// sqlfluff:dialect:sqlite
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
GetSessionByID(ctx context.Context, id string) (Session, error)
|
||||
ListSessions(ctx context.Context) ([]Session, error)
|
||||
UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
165
internal/db/sessions.sql.go
Normal file
165
internal/db/sessions.sql.go
Normal file
|
@ -0,0 +1,165 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.27.0
|
||||
// source: sessions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createSession = `-- name: CreateSession :one
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
title,
|
||||
message_count,
|
||||
tokens,
|
||||
cost,
|
||||
updated_at,
|
||||
created_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now')
|
||||
) RETURNING id, title, message_count, tokens, cost, updated_at, created_at
|
||||
`
|
||||
|
||||
type CreateSessionParams struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
MessageCount int64 `json:"message_count"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
}
|
||||
|
||||
// sqlfluff:dialect:sqlite
|
||||
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
|
||||
row := q.queryRow(ctx, q.createSessionStmt, createSession,
|
||||
arg.ID,
|
||||
arg.Title,
|
||||
arg.MessageCount,
|
||||
arg.Tokens,
|
||||
arg.Cost,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.Tokens,
|
||||
&i.Cost,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSession(ctx context.Context, id string) error {
|
||||
_, err := q.exec(ctx, q.deleteSessionStmt, deleteSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSessionByID = `-- name: GetSessionByID :one
|
||||
SELECT id, title, message_count, tokens, cost, updated_at, created_at
|
||||
FROM sessions
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error) {
|
||||
row := q.queryRow(ctx, q.getSessionByIDStmt, getSessionByID, id)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.Tokens,
|
||||
&i.Cost,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSessions = `-- name: ListSessions :many
|
||||
SELECT id, title, message_count, tokens, cost, updated_at, created_at
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
|
||||
rows, err := q.query(ctx, q.listSessionsStmt, listSessions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Session{}
|
||||
for rows.Next() {
|
||||
var i Session
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.Tokens,
|
||||
&i.Cost,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateSession = `-- name: UpdateSession :one
|
||||
UPDATE sessions
|
||||
SET
|
||||
title = ?,
|
||||
tokens = ?,
|
||||
cost = ?
|
||||
WHERE id = ?
|
||||
RETURNING id, title, message_count, tokens, cost, updated_at, created_at
|
||||
`
|
||||
|
||||
type UpdateSessionParams struct {
|
||||
Title string `json:"title"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Cost float64 `json:"cost"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
|
||||
row := q.queryRow(ctx, q.updateSessionStmt, updateSession,
|
||||
arg.Title,
|
||||
arg.Tokens,
|
||||
arg.Cost,
|
||||
arg.ID,
|
||||
)
|
||||
var i Session
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.MessageCount,
|
||||
&i.Tokens,
|
||||
&i.Cost,
|
||||
&i.UpdatedAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
43
internal/db/sql/sessions.sql
Normal file
43
internal/db/sql/sessions.sql
Normal file
|
@ -0,0 +1,43 @@
|
|||
-- sqlfluff:dialect:sqlite
|
||||
-- name: CreateSession :one
|
||||
INSERT INTO sessions (
|
||||
id,
|
||||
title,
|
||||
message_count,
|
||||
tokens,
|
||||
cost,
|
||||
updated_at,
|
||||
created_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now')
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetSessionByID :one
|
||||
SELECT *
|
||||
FROM sessions
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: ListSessions :many
|
||||
SELECT *
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateSession :one
|
||||
UPDATE sessions
|
||||
SET
|
||||
title = ?,
|
||||
tokens = ?,
|
||||
cost = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?;
|
|
@ -1,11 +1,17 @@
|
|||
package pubsub
|
||||
|
||||
import "context"
|
||||
|
||||
const (
|
||||
CreatedEvent EventType = "created"
|
||||
UpdatedEvent EventType = "updated"
|
||||
DeletedEvent EventType = "deleted"
|
||||
)
|
||||
|
||||
type Suscriber[T any] interface {
|
||||
Subscribe(context.Context) <-chan Event[T]
|
||||
}
|
||||
|
||||
type (
|
||||
// EventType identifies the type of event
|
||||
EventType string
|
||||
|
|
116
internal/session/session.go
Normal file
116
internal/session/session.go
Normal file
|
@ -0,0 +1,116 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kujtimiihoxha/termai/internal/db"
|
||||
"github.com/kujtimiihoxha/termai/internal/pubsub"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
Title string
|
||||
MessageCount int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
pubsub.Suscriber[Session]
|
||||
Create(title string) (Session, error)
|
||||
Get(id string) (Session, error)
|
||||
List() ([]Session, error)
|
||||
Save(session Session) (Session, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
*pubsub.Broker[Session]
|
||||
q db.Querier
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (s *service) Create(title string) (Session, error) {
|
||||
dbSession, err := s.q.CreateSession(s.ctx, db.CreateSessionParams{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session := s.fromDBItem(dbSession)
|
||||
s.Publish(pubsub.CreatedEvent, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(id string) error {
|
||||
session, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.q.DeleteSession(s.ctx, session.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Publish(pubsub.DeletedEvent, session)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Get(id string) (Session, error) {
|
||||
dbSession, err := s.q.GetSessionByID(s.ctx, id)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return s.fromDBItem(dbSession), nil
|
||||
}
|
||||
|
||||
func (s *service) Save(session Session) (Session, error) {
|
||||
dbSession, err := s.q.UpdateSession(s.ctx, db.UpdateSessionParams{
|
||||
ID: session.ID,
|
||||
Title: session.Title,
|
||||
Tokens: session.Tokens,
|
||||
Cost: session.Cost,
|
||||
})
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
session = s.fromDBItem(dbSession)
|
||||
s.Publish(pubsub.UpdatedEvent, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *service) List() ([]Session, error) {
|
||||
dbSessions, err := s.q.ListSessions(s.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions := make([]Session, len(dbSessions))
|
||||
for i, dbSession := range dbSessions {
|
||||
sessions[i] = s.fromDBItem(dbSession)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s service) fromDBItem(item db.Session) Session {
|
||||
return Session{
|
||||
ID: item.ID,
|
||||
Title: item.Title,
|
||||
MessageCount: item.MessageCount,
|
||||
Tokens: item.Tokens,
|
||||
Cost: item.Cost,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, q db.Querier) Service {
|
||||
broker := pubsub.NewBroker[Session]()
|
||||
return &service{
|
||||
broker,
|
||||
q,
|
||||
ctx,
|
||||
}
|
||||
}
|
84
internal/tui/components/core/dialog.go
Normal file
84
internal/tui/components/core/dialog.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
)
|
||||
|
||||
type SizeableModel interface {
|
||||
tea.Model
|
||||
layout.Sizeable
|
||||
}
|
||||
|
||||
type DialogMsg struct {
|
||||
Content SizeableModel
|
||||
}
|
||||
|
||||
type DialogCloseMsg struct{}
|
||||
|
||||
type KeyBindings struct {
|
||||
Return key.Binding
|
||||
}
|
||||
|
||||
var keys = KeyBindings{
|
||||
Return: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
}
|
||||
|
||||
type DialogCmp interface {
|
||||
tea.Model
|
||||
layout.Bindings
|
||||
}
|
||||
|
||||
type dialogCmp struct {
|
||||
content SizeableModel
|
||||
}
|
||||
|
||||
func (d *dialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case DialogMsg:
|
||||
d.content = msg.Content
|
||||
case DialogCloseMsg:
|
||||
d.content = nil
|
||||
return d, nil
|
||||
case tea.KeyMsg:
|
||||
if key.Matches(msg, keys.Return) {
|
||||
return d, util.CmdHandler(DialogCloseMsg{})
|
||||
}
|
||||
}
|
||||
if d.content != nil {
|
||||
u, cmd := d.content.Update(msg)
|
||||
d.content = u.(SizeableModel)
|
||||
return d, cmd
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *dialogCmp) BindingKeys() []key.Binding {
|
||||
bindings := []key.Binding{keys.Return}
|
||||
if d.content == nil {
|
||||
return bindings
|
||||
}
|
||||
if c, ok := d.content.(layout.Bindings); ok {
|
||||
return append(bindings, c.BindingKeys()...)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (d *dialogCmp) View() string {
|
||||
w, h := d.content.GetSize()
|
||||
return lipgloss.NewStyle().Width(w).Height(h).Render(d.content.View())
|
||||
}
|
||||
|
||||
func NewDialogCmp() DialogCmp {
|
||||
return &dialogCmp{}
|
||||
}
|
|
@ -24,23 +24,23 @@ type helpCmp struct {
|
|||
bindings []key.Binding
|
||||
}
|
||||
|
||||
func (m *helpCmp) Init() tea.Cmd {
|
||||
func (h *helpCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
h.width = msg.Width
|
||||
}
|
||||
return m, nil
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (m *helpCmp) View() string {
|
||||
func (h *helpCmp) View() string {
|
||||
helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
|
||||
helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
|
||||
// Compile list of bindings to render
|
||||
bindings := removeDuplicateBindings(m.bindings)
|
||||
bindings := removeDuplicateBindings(h.bindings)
|
||||
// Enumerate through each group of bindings, populating a series of
|
||||
// pairs of columns, one for keys, one for descriptions
|
||||
var (
|
||||
|
@ -72,7 +72,7 @@ func (m *helpCmp) View() string {
|
|||
// check whether it exceeds the maximum width avail (the width of the
|
||||
// terminal, subtracting 2 for the borders).
|
||||
width += lipgloss.Width(pair)
|
||||
if width > m.width-2 {
|
||||
if width > h.width-2 {
|
||||
break
|
||||
}
|
||||
pairs = append(pairs, pair)
|
||||
|
@ -80,7 +80,7 @@ func (m *helpCmp) View() string {
|
|||
|
||||
// Join pairs of columns and enclose in a border
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
|
||||
return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(m.width - 2).Render(content)
|
||||
return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
|
||||
}
|
||||
|
||||
func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
|
||||
|
@ -103,11 +103,11 @@ func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
|
|||
return result
|
||||
}
|
||||
|
||||
func (m *helpCmp) SetBindings(bindings []key.Binding) {
|
||||
m.bindings = bindings
|
||||
func (h *helpCmp) SetBindings(bindings []key.Binding) {
|
||||
h.bindings = bindings
|
||||
}
|
||||
|
||||
func (m helpCmp) Height() int {
|
||||
func (h helpCmp) Height() int {
|
||||
return helpWidgetHeight
|
||||
}
|
||||
|
||||
|
|
111
internal/tui/components/dialog/quit.go
Normal file
111
internal/tui/components/dialog/quit.go
Normal file
|
@ -0,0 +1,111 @@
|
|||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
const question = "Are you sure you want to quit?"
|
||||
|
||||
var (
|
||||
width = lipgloss.Width(question) + 6
|
||||
height = 3
|
||||
)
|
||||
|
||||
type QuitDialog interface {
|
||||
tea.Model
|
||||
layout.Sizeable
|
||||
layout.Bindings
|
||||
}
|
||||
|
||||
type quitDialogCmp struct {
|
||||
form *huh.Form
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
// Process the form
|
||||
form, cmd := q.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
q.form = f
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
if q.form.State == huh.StateCompleted {
|
||||
v := q.form.GetBool("quit")
|
||||
if v {
|
||||
return q, tea.Quit
|
||||
}
|
||||
cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
|
||||
}
|
||||
|
||||
return q, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) View() string {
|
||||
return q.form.View()
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) GetSize() (int, int) {
|
||||
return q.width, q.height
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) SetSize(width int, height int) {
|
||||
q.width = width
|
||||
q.height = height
|
||||
}
|
||||
|
||||
func (q *quitDialogCmp) BindingKeys() []key.Binding {
|
||||
return q.form.KeyBinds()
|
||||
}
|
||||
|
||||
func newQuitDialogCmp() QuitDialog {
|
||||
confirm := huh.NewConfirm().
|
||||
Title(question).
|
||||
Affirmative("Yes!").
|
||||
Key("quit").
|
||||
Negative("No.")
|
||||
|
||||
theme := styles.HuhTheme()
|
||||
theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
|
||||
theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
|
||||
form := huh.NewForm(huh.NewGroup(confirm)).
|
||||
WithWidth(width).
|
||||
WithHeight(height).
|
||||
WithShowHelp(false).
|
||||
WithTheme(theme).
|
||||
WithShowErrors(false)
|
||||
confirm.Focus()
|
||||
return &quitDialogCmp{
|
||||
form: form,
|
||||
width: width,
|
||||
}
|
||||
}
|
||||
|
||||
func NewQuitDialogCmd() tea.Cmd {
|
||||
content := layout.NewSinglePane(
|
||||
newQuitDialogCmp().(*quitDialogCmp),
|
||||
layout.WithSignlePaneSize(width+2, height+2),
|
||||
layout.WithSinglePaneBordered(true),
|
||||
layout.WithSinglePaneFocusable(true),
|
||||
layout.WithSinglePaneActiveColor(styles.Warning),
|
||||
)
|
||||
content.Focus()
|
||||
return util.CmdHandler(core.DialogMsg{
|
||||
Content: content,
|
||||
})
|
||||
}
|
|
@ -1,21 +1,130 @@
|
|||
package repl
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/vimtea"
|
||||
)
|
||||
|
||||
type editorCmp struct{}
|
||||
type EditorCmp interface {
|
||||
tea.Model
|
||||
layout.Focusable
|
||||
layout.Sizeable
|
||||
layout.Bordered
|
||||
}
|
||||
|
||||
func (i *editorCmp) Init() tea.Cmd {
|
||||
type editorCmp struct {
|
||||
app *app.App
|
||||
editor vimtea.Editor
|
||||
editorMode vimtea.EditorMode
|
||||
sessionID string
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type localKeyMap struct {
|
||||
SendMessage key.Binding
|
||||
SendMessageI key.Binding
|
||||
}
|
||||
|
||||
var keyMap = localKeyMap{
|
||||
SendMessage: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "send message normal mode"),
|
||||
),
|
||||
SendMessageI: key.NewBinding(
|
||||
key.WithKeys("ctrl+s"),
|
||||
key.WithHelp("ctrl+s", "send message insert mode"),
|
||||
),
|
||||
}
|
||||
|
||||
func (m *editorCmp) Init() tea.Cmd {
|
||||
return m.editor.Init()
|
||||
}
|
||||
|
||||
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case vimtea.EditorModeMsg:
|
||||
m.editorMode = msg.Mode
|
||||
case SelectedSessionMsg:
|
||||
if msg.SessionID != m.sessionID {
|
||||
m.sessionID = msg.SessionID
|
||||
}
|
||||
}
|
||||
if m.IsFocused() {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.SendMessage):
|
||||
if m.editorMode == vimtea.ModeNormal {
|
||||
return m, m.Send()
|
||||
}
|
||||
case key.Matches(msg, keyMap.SendMessageI):
|
||||
if m.editorMode == vimtea.ModeInsert {
|
||||
return m, m.Send()
|
||||
}
|
||||
}
|
||||
}
|
||||
u, cmd := m.editor.Update(msg)
|
||||
m.editor = u.(vimtea.Editor)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Blur implements EditorCmp.
|
||||
func (m *editorCmp) Blur() tea.Cmd {
|
||||
m.focused = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return i, nil
|
||||
// BorderText implements EditorCmp.
|
||||
func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
|
||||
return map[layout.BorderPosition]string{
|
||||
layout.TopLeftBorder: "New Message",
|
||||
}
|
||||
}
|
||||
|
||||
func (i *editorCmp) View() string {
|
||||
return "Editor"
|
||||
// Focus implements EditorCmp.
|
||||
func (m *editorCmp) Focus() tea.Cmd {
|
||||
m.focused = true
|
||||
return m.editor.Tick()
|
||||
}
|
||||
|
||||
func NewEditorCmp() tea.Model {
|
||||
return &editorCmp{}
|
||||
// GetSize implements EditorCmp.
|
||||
func (m *editorCmp) GetSize() (int, int) {
|
||||
return m.width, m.height
|
||||
}
|
||||
|
||||
// IsFocused implements EditorCmp.
|
||||
func (m *editorCmp) IsFocused() bool {
|
||||
return m.focused
|
||||
}
|
||||
|
||||
// SetSize implements EditorCmp.
|
||||
func (m *editorCmp) SetSize(width int, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.editor.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (m *editorCmp) Send() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// TODO: Send message
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorCmp) View() string {
|
||||
return m.editor.View()
|
||||
}
|
||||
|
||||
func NewEditorCmp(app *app.App) EditorCmp {
|
||||
return &editorCmp{
|
||||
app: app,
|
||||
editor: vimtea.NewEditor(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
package repl
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
)
|
||||
|
||||
type messagesCmp struct{}
|
||||
type messagesCmp struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (i *messagesCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
|
@ -16,6 +21,8 @@ func (i *messagesCmp) View() string {
|
|||
return "Messages"
|
||||
}
|
||||
|
||||
func NewMessagesCmp() tea.Model {
|
||||
return &messagesCmp{}
|
||||
func NewMessagesCmp(app *app.App) tea.Model {
|
||||
return &messagesCmp{
|
||||
app,
|
||||
}
|
||||
}
|
||||
|
|
161
internal/tui/components/repl/sessions.go
Normal file
161
internal/tui/components/repl/sessions.go
Normal file
|
@ -0,0 +1,161 @@
|
|||
package repl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/session"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/styles"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
)
|
||||
|
||||
type SessionsCmp interface {
|
||||
tea.Model
|
||||
layout.Sizeable
|
||||
layout.Focusable
|
||||
layout.Bordered
|
||||
layout.Bindings
|
||||
}
|
||||
type sessionsCmp struct {
|
||||
app *app.App
|
||||
list list.Model
|
||||
focused bool
|
||||
}
|
||||
|
||||
type listItem struct {
|
||||
id, title, desc string
|
||||
}
|
||||
|
||||
func (i listItem) Title() string { return i.title }
|
||||
func (i listItem) Description() string { return i.desc }
|
||||
func (i listItem) FilterValue() string { return i.title }
|
||||
|
||||
type InsertSessionsMsg struct {
|
||||
sessions []session.Session
|
||||
}
|
||||
|
||||
type SelectedSessionMsg struct {
|
||||
SessionID string
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) Init() tea.Cmd {
|
||||
existing, err := i.app.Sessions.List()
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
if len(existing) == 0 || existing[0].MessageCount > 0 {
|
||||
session, err := i.app.Sessions.Create(
|
||||
"New Session",
|
||||
)
|
||||
if err != nil {
|
||||
return util.ReportError(err)
|
||||
}
|
||||
existing = append(existing, session)
|
||||
}
|
||||
return tea.Batch(
|
||||
util.CmdHandler(InsertSessionsMsg{existing}),
|
||||
util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
|
||||
)
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case InsertSessionsMsg:
|
||||
items := make([]list.Item, len(msg.sessions))
|
||||
for i, s := range msg.sessions {
|
||||
items[i] = listItem{
|
||||
id: s.ID,
|
||||
title: s.Title,
|
||||
desc: fmt.Sprintf("Tokens: %d, Cost: %.2f", s.Tokens, s.Cost),
|
||||
}
|
||||
}
|
||||
return i, i.list.SetItems(items)
|
||||
}
|
||||
if i.focused {
|
||||
u, cmd := i.list.Update(msg)
|
||||
i.list = u
|
||||
return i, cmd
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) View() string {
|
||||
return i.list.View()
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) Blur() tea.Cmd {
|
||||
i.focused = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) Focus() tea.Cmd {
|
||||
i.focused = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) GetSize() (int, int) {
|
||||
return i.list.Width(), i.list.Height()
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) IsFocused() bool {
|
||||
return i.focused
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) SetSize(width int, height int) {
|
||||
i.list.SetSize(width, height)
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
|
||||
totalCount := len(i.list.Items())
|
||||
itemsPerPage := i.list.Paginator.PerPage
|
||||
currentPage := i.list.Paginator.Page
|
||||
|
||||
current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
|
||||
|
||||
pageInfo := fmt.Sprintf(
|
||||
"%d-%d of %d",
|
||||
currentPage*itemsPerPage+1,
|
||||
current,
|
||||
totalCount,
|
||||
)
|
||||
return map[layout.BorderPosition]string{
|
||||
layout.TopMiddleBorder: "Sessions",
|
||||
layout.BottomMiddleBorder: pageInfo,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *sessionsCmp) BindingKeys() []key.Binding {
|
||||
return layout.KeyMapToSlice(i.list.KeyMap)
|
||||
}
|
||||
|
||||
func NewSessionsCmp(app *app.App) SessionsCmp {
|
||||
listDelegate := list.NewDefaultDelegate()
|
||||
defaultItemStyle := list.NewDefaultItemStyles()
|
||||
defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
|
||||
defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
|
||||
|
||||
defaultStyle := list.DefaultStyles()
|
||||
defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
|
||||
defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
|
||||
|
||||
listDelegate.Styles = defaultItemStyle
|
||||
|
||||
listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
|
||||
listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
|
||||
listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
|
||||
listComponent.SetShowTitle(false)
|
||||
listComponent.SetShowPagination(false)
|
||||
listComponent.SetShowHelp(false)
|
||||
listComponent.SetShowStatusBar(false)
|
||||
listComponent.DisableQuitKeybindings()
|
||||
|
||||
return &sessionsCmp{
|
||||
app: app,
|
||||
list: listComponent,
|
||||
focused: false,
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
package repl
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type threadsCmp struct{}
|
||||
|
||||
func (i *threadsCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *threadsCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (i *threadsCmp) View() string {
|
||||
return "Threads"
|
||||
}
|
||||
|
||||
func NewThreadsCmp() tea.Model {
|
||||
return &threadsCmp{}
|
||||
}
|
|
@ -73,7 +73,14 @@ func (b *bentoLayout) GetSize() (int, int) {
|
|||
}
|
||||
|
||||
func (b *bentoLayout) Init() tea.Cmd {
|
||||
return nil
|
||||
var cmds []tea.Cmd
|
||||
for _, pane := range b.panes {
|
||||
cmd := pane.Init()
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (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) {
|
|||
}
|
||||
}
|
||||
|
||||
if pane, ok := b.panes[b.currentPane]; ok {
|
||||
var cmds []tea.Cmd
|
||||
for id, pane := range b.panes {
|
||||
u, cmd := pane.Update(msg)
|
||||
b.panes[b.currentPane] = u.(SinglePaneLayout)
|
||||
return b, cmd
|
||||
b.panes[id] = u.(SinglePaneLayout)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
return b, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (b *bentoLayout) View() string {
|
||||
|
|
|
@ -24,17 +24,20 @@ var (
|
|||
InactivePreviewBorder = styles.Grey
|
||||
)
|
||||
|
||||
func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
|
||||
func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string {
|
||||
if embeddedText == nil {
|
||||
embeddedText = make(map[BorderPosition]string)
|
||||
}
|
||||
if activeColor == nil {
|
||||
activeColor = ActiveBorder
|
||||
}
|
||||
var (
|
||||
thickness = map[bool]lipgloss.Border{
|
||||
true: lipgloss.Border(lipgloss.ThickBorder()),
|
||||
false: lipgloss.Border(lipgloss.NormalBorder()),
|
||||
}
|
||||
color = map[bool]lipgloss.TerminalColor{
|
||||
true: ActiveBorder,
|
||||
true: activeColor,
|
||||
false: InactivePreviewBorder,
|
||||
}
|
||||
border = thickness[active]
|
||||
|
|
205
internal/tui/layout/overlay.go
Normal file
205
internal/tui/layout/overlay.go
Normal file
|
@ -0,0 +1,205 @@
|
|||
package layout
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
// Most of this code is borrowed from
|
||||
// https://github.com/charmbracelet/lipgloss/pull/102
|
||||
// as well as the lipgloss library, with some modification for what I needed.
|
||||
|
||||
// Split a string into lines, additionally returning the size of the widest
|
||||
// line.
|
||||
func getLines(s string) (lines []string, widest int) {
|
||||
lines = strings.Split(s, "\n")
|
||||
|
||||
for _, l := range lines {
|
||||
w := ansi.PrintableRuneWidth(l)
|
||||
if widest < w {
|
||||
widest = w
|
||||
}
|
||||
}
|
||||
|
||||
return lines, widest
|
||||
}
|
||||
|
||||
// PlaceOverlay places fg on top of bg.
|
||||
func PlaceOverlay(
|
||||
x, y int,
|
||||
fg, bg string,
|
||||
shadow bool, opts ...WhitespaceOption,
|
||||
) string {
|
||||
fgLines, fgWidth := getLines(fg)
|
||||
bgLines, bgWidth := getLines(bg)
|
||||
bgHeight := len(bgLines)
|
||||
fgHeight := len(fgLines)
|
||||
|
||||
if shadow {
|
||||
var shadowbg string = ""
|
||||
shadowchar := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#333333")).
|
||||
Render("░")
|
||||
for i := 0; i <= fgHeight; i++ {
|
||||
if i == 0 {
|
||||
shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
|
||||
} else {
|
||||
shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...)
|
||||
fgLines, fgWidth = getLines(fg)
|
||||
fgHeight = len(fgLines)
|
||||
}
|
||||
|
||||
if fgWidth >= bgWidth && fgHeight >= bgHeight {
|
||||
// FIXME: return fg or bg?
|
||||
return fg
|
||||
}
|
||||
// TODO: allow placement outside of the bg box?
|
||||
x = util.Clamp(x, 0, bgWidth-fgWidth)
|
||||
y = util.Clamp(y, 0, bgHeight-fgHeight)
|
||||
|
||||
ws := &whitespace{}
|
||||
for _, opt := range opts {
|
||||
opt(ws)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, bgLine := range bgLines {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if i < y || i >= y+fgHeight {
|
||||
b.WriteString(bgLine)
|
||||
continue
|
||||
}
|
||||
|
||||
pos := 0
|
||||
if x > 0 {
|
||||
left := truncate.String(bgLine, uint(x))
|
||||
pos = ansi.PrintableRuneWidth(left)
|
||||
b.WriteString(left)
|
||||
if pos < x {
|
||||
b.WriteString(ws.render(x - pos))
|
||||
pos = x
|
||||
}
|
||||
}
|
||||
|
||||
fgLine := fgLines[i-y]
|
||||
b.WriteString(fgLine)
|
||||
pos += ansi.PrintableRuneWidth(fgLine)
|
||||
|
||||
right := cutLeft(bgLine, pos)
|
||||
bgWidth := ansi.PrintableRuneWidth(bgLine)
|
||||
rightWidth := ansi.PrintableRuneWidth(right)
|
||||
if rightWidth <= bgWidth-pos {
|
||||
b.WriteString(ws.render(bgWidth - rightWidth - pos))
|
||||
}
|
||||
|
||||
b.WriteString(right)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cutLeft cuts printable characters from the left.
|
||||
// This function is heavily based on muesli's ansi and truncate packages.
|
||||
func cutLeft(s string, cutWidth int) string {
|
||||
var (
|
||||
pos int
|
||||
isAnsi bool
|
||||
ab bytes.Buffer
|
||||
b bytes.Buffer
|
||||
)
|
||||
for _, c := range s {
|
||||
var w int
|
||||
if c == ansi.Marker || isAnsi {
|
||||
isAnsi = true
|
||||
ab.WriteRune(c)
|
||||
if ansi.IsTerminator(c) {
|
||||
isAnsi = false
|
||||
if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) {
|
||||
ab.Reset()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
w = runewidth.RuneWidth(c)
|
||||
}
|
||||
|
||||
if pos >= cutWidth {
|
||||
if b.Len() == 0 {
|
||||
if ab.Len() > 0 {
|
||||
b.Write(ab.Bytes())
|
||||
}
|
||||
if pos-cutWidth > 1 {
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
pos += w
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type whitespace struct {
|
||||
style termenv.Style
|
||||
chars string
|
||||
}
|
||||
|
||||
// Render whitespaces.
|
||||
func (w whitespace) render(width int) string {
|
||||
if w.chars == "" {
|
||||
w.chars = " "
|
||||
}
|
||||
|
||||
r := []rune(w.chars)
|
||||
j := 0
|
||||
b := strings.Builder{}
|
||||
|
||||
// Cycle through runes and print them into the whitespace.
|
||||
for i := 0; i < width; {
|
||||
b.WriteRune(r[j])
|
||||
j++
|
||||
if j >= len(r) {
|
||||
j = 0
|
||||
}
|
||||
i += ansi.PrintableRuneWidth(string(r[j]))
|
||||
}
|
||||
|
||||
// Fill any extra gaps white spaces. This might be necessary if any runes
|
||||
// are more than one cell wide, which could leave a one-rune gap.
|
||||
short := width - ansi.PrintableRuneWidth(b.String())
|
||||
if short > 0 {
|
||||
b.WriteString(strings.Repeat(" ", short))
|
||||
}
|
||||
|
||||
return w.style.Styled(b.String())
|
||||
}
|
||||
|
||||
// WhitespaceOption sets a styling rule for rendering whitespace.
|
||||
type WhitespaceOption func(*whitespace)
|
|
@ -11,6 +11,7 @@ type SinglePaneLayout interface {
|
|||
Focusable
|
||||
Sizeable
|
||||
Bindings
|
||||
Pane() tea.Model
|
||||
}
|
||||
|
||||
type singlePaneLayout struct {
|
||||
|
@ -26,6 +27,8 @@ type singlePaneLayout struct {
|
|||
content tea.Model
|
||||
|
||||
padding []int
|
||||
|
||||
activeColor lipgloss.TerminalColor
|
||||
}
|
||||
|
||||
type SinglePaneOption func(*singlePaneLayout)
|
||||
|
@ -48,7 +51,7 @@ func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
func (s *singlePaneLayout) View() string {
|
||||
style := lipgloss.NewStyle().Width(s.width).Height(s.height)
|
||||
if s.bordered {
|
||||
style = style.Width(s.width).Height(s.height)
|
||||
style = style.Width(s.width - 2).Height(s.height - 2)
|
||||
}
|
||||
if s.padding != nil {
|
||||
style = style.Padding(s.padding...)
|
||||
|
@ -61,7 +64,7 @@ func (s *singlePaneLayout) View() string {
|
|||
if bordered, ok := s.content.(Bordered); ok {
|
||||
s.borderText = bordered.BorderText()
|
||||
}
|
||||
return Borderize(content, s.focused, s.borderText)
|
||||
return Borderize(content, s.focused, s.borderText, s.activeColor)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
@ -89,11 +92,11 @@ func (s *singlePaneLayout) Focus() tea.Cmd {
|
|||
func (s *singlePaneLayout) SetSize(width, height int) {
|
||||
s.width = width
|
||||
s.height = height
|
||||
if s.bordered {
|
||||
s.width -= 2
|
||||
s.height -= 2
|
||||
}
|
||||
childWidth, childHeight := s.width, s.height
|
||||
if s.bordered {
|
||||
childWidth -= 2
|
||||
childHeight -= 2
|
||||
}
|
||||
if s.padding != nil {
|
||||
if len(s.padding) == 1 {
|
||||
childWidth -= s.padding[0] * 2
|
||||
|
@ -131,6 +134,10 @@ func (s *singlePaneLayout) BindingKeys() []key.Binding {
|
|||
return []key.Binding{}
|
||||
}
|
||||
|
||||
func (s *singlePaneLayout) Pane() tea.Model {
|
||||
return s.content
|
||||
}
|
||||
|
||||
func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
|
||||
layout := &singlePaneLayout{
|
||||
content: content,
|
||||
|
@ -171,3 +178,9 @@ func WithSinglePanePadding(padding ...int) SinglePaneOption {
|
|||
opts.padding = padding
|
||||
}
|
||||
}
|
||||
|
||||
func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
|
||||
return func(opts *singlePaneLayout) {
|
||||
opts.activeColor = color
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,18 +2,19 @@ package page
|
|||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/repl"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
)
|
||||
|
||||
var ReplPage PageID = "repl"
|
||||
|
||||
func NewReplPage() tea.Model {
|
||||
func NewReplPage(app *app.App) tea.Model {
|
||||
return layout.NewBentoLayout(
|
||||
layout.BentoPanes{
|
||||
layout.BentoLeftPane: repl.NewThreadsCmp(),
|
||||
layout.BentoRightTopPane: repl.NewMessagesCmp(),
|
||||
layout.BentoRightBottomPane: repl.NewEditorCmp(),
|
||||
layout.BentoLeftPane: repl.NewSessionsCmp(app),
|
||||
layout.BentoRightTopPane: repl.NewMessagesCmp(app),
|
||||
layout.BentoRightBottomPane: repl.NewEditorCmp(app),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
46
internal/tui/styles/huh.go
Normal file
46
internal/tui/styles/huh.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package styles
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func HuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
t.Focused.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
t.Focused.Title = t.Focused.Title.Foreground(Text)
|
||||
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(Text)
|
||||
t.Focused.Directory = t.Focused.Directory.Foreground(Text)
|
||||
t.Focused.Description = t.Focused.Description.Foreground(SubText0)
|
||||
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(Red)
|
||||
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(Red)
|
||||
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(Blue)
|
||||
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(Blue)
|
||||
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(Blue)
|
||||
t.Focused.Option = t.Focused.Option.Foreground(Text)
|
||||
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(Blue)
|
||||
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(Green)
|
||||
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(Green)
|
||||
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(Text)
|
||||
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(Text)
|
||||
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(Base).Background(Blue)
|
||||
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(Text).Background(Base)
|
||||
|
||||
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(Teal)
|
||||
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(Overlay0)
|
||||
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(Blue)
|
||||
|
||||
t.Blurred = t.Focused
|
||||
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
|
||||
t.Help.Ellipsis = t.Help.Ellipsis.Foreground(SubText0)
|
||||
t.Help.ShortKey = t.Help.ShortKey.Foreground(SubText0)
|
||||
t.Help.ShortDesc = t.Help.ShortDesc.Foreground(Ovelay1)
|
||||
t.Help.ShortSeparator = t.Help.ShortSeparator.Foreground(SubText0)
|
||||
t.Help.FullKey = t.Help.FullKey.Foreground(SubText0)
|
||||
t.Help.FullDesc = t.Help.FullDesc.Foreground(Ovelay1)
|
||||
t.Help.FullSeparator = t.Help.FullSeparator.Foreground(SubText0)
|
||||
|
||||
return t
|
||||
}
|
|
@ -122,4 +122,7 @@ var (
|
|||
|
||||
Primary = Blue
|
||||
Secondary = Mauve
|
||||
|
||||
Warning = Peach
|
||||
Error = Red
|
||||
)
|
||||
|
|
|
@ -4,10 +4,13 @@ import (
|
|||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/kujtimiihoxha/termai/internal/app"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/core"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/layout"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/page"
|
||||
"github.com/kujtimiihoxha/termai/internal/tui/util"
|
||||
"github.com/kujtimiihoxha/vimtea"
|
||||
)
|
||||
|
||||
type keyMap struct {
|
||||
|
@ -49,6 +52,9 @@ type appModel struct {
|
|||
loadedPages map[page.PageID]bool
|
||||
status tea.Model
|
||||
help core.HelpCmp
|
||||
dialog core.DialogCmp
|
||||
dialogVisible bool
|
||||
editorMode vimtea.EditorMode
|
||||
showHelp bool
|
||||
}
|
||||
|
||||
|
@ -60,6 +66,8 @@ func (a appModel) Init() tea.Cmd {
|
|||
|
||||
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case vimtea.EditorModeMsg:
|
||||
a.editorMode = msg.Mode
|
||||
case tea.WindowSizeMsg:
|
||||
msg.Height -= 1 // Make space for the status bar
|
||||
a.width, a.height = msg.Width, msg.Height
|
||||
|
@ -72,31 +80,47 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = p
|
||||
return a, cmd
|
||||
case core.DialogMsg:
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
a.dialogVisible = true
|
||||
return a, cmd
|
||||
case core.DialogCloseMsg:
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
a.dialogVisible = false
|
||||
return a, cmd
|
||||
case util.InfoMsg:
|
||||
a.status, _ = a.status.Update(msg)
|
||||
case util.ErrorMsg:
|
||||
a.status, _ = a.status.Update(msg)
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, tea.Quit
|
||||
case key.Matches(msg, keys.Back):
|
||||
if a.previousPage != "" {
|
||||
return a, a.moveToPage(a.previousPage)
|
||||
}
|
||||
case key.Matches(msg, keys.Return):
|
||||
if a.showHelp {
|
||||
if a.editorMode == vimtea.ModeNormal {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, dialog.NewQuitDialogCmd()
|
||||
case key.Matches(msg, keys.Back):
|
||||
if a.previousPage != "" {
|
||||
return a, a.moveToPage(a.previousPage)
|
||||
}
|
||||
case key.Matches(msg, keys.Return):
|
||||
if a.showHelp {
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
case key.Matches(msg, keys.Logs):
|
||||
return a, a.moveToPage(page.LogsPage)
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
return a, nil
|
||||
case key.Matches(msg, keys.Logs):
|
||||
return a, a.moveToPage(page.LogsPage)
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.ToggleHelp()
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
if a.dialogVisible {
|
||||
d, cmd := a.dialog.Update(msg)
|
||||
a.dialog = d.(core.DialogCmp)
|
||||
return a, cmd
|
||||
}
|
||||
p, cmd := a.pages[a.currentPage].Update(msg)
|
||||
a.pages[a.currentPage] = p
|
||||
return a, cmd
|
||||
|
@ -141,25 +165,45 @@ func (a appModel) View() string {
|
|||
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
|
||||
bindings = append(bindings, p.BindingKeys()...)
|
||||
}
|
||||
if a.dialogVisible {
|
||||
bindings = append(bindings, a.dialog.BindingKeys()...)
|
||||
}
|
||||
a.help.SetBindings(bindings)
|
||||
components = append(components, a.help.View())
|
||||
}
|
||||
|
||||
components = append(components, a.status.View())
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Top, components...)
|
||||
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
|
||||
|
||||
if a.dialogVisible {
|
||||
overlay := a.dialog.View()
|
||||
row := lipgloss.Height(appView) / 2
|
||||
row -= lipgloss.Height(overlay) / 2
|
||||
col := lipgloss.Width(appView) / 2
|
||||
col -= lipgloss.Width(overlay) / 2
|
||||
appView = layout.PlaceOverlay(
|
||||
col,
|
||||
row,
|
||||
overlay,
|
||||
appView,
|
||||
true,
|
||||
)
|
||||
}
|
||||
return appView
|
||||
}
|
||||
|
||||
func New() tea.Model {
|
||||
func New(app *app.App) tea.Model {
|
||||
return &appModel{
|
||||
currentPage: page.ReplPage,
|
||||
loadedPages: make(map[page.PageID]bool),
|
||||
status: core.NewStatusCmp(),
|
||||
help: core.NewHelpCmp(),
|
||||
dialog: core.NewDialogCmp(),
|
||||
pages: map[page.PageID]tea.Model{
|
||||
page.LogsPage: page.NewLogsPage(),
|
||||
page.InitPage: page.NewInitPage(),
|
||||
page.ReplPage: page.NewReplPage(),
|
||||
page.ReplPage: page.NewReplPage(app),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,3 +16,10 @@ type (
|
|||
InfoMsg string
|
||||
ErrorMsg error
|
||||
)
|
||||
|
||||
func Clamp(v, low, high int) int {
|
||||
if high < low {
|
||||
low, high = high, low
|
||||
}
|
||||
return min(high, max(low, v))
|
||||
}
|
||||
|
|
11
main.go
Normal file
11
main.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/kujtimiihoxha/termai/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
14
sqlc.yaml
Normal file
14
sqlc.yaml
Normal file
|
@ -0,0 +1,14 @@
|
|||
version: "2"
|
||||
sql:
|
||||
- engine: "sqlite"
|
||||
schema: "internal/db/migrations"
|
||||
queries: "internal/db/sql"
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "internal/db"
|
||||
emit_json_tags: true
|
||||
emit_prepared_queries: true
|
||||
emit_interface: true
|
||||
emit_exact_table_names: false
|
||||
emit_empty_slices: true
|
Loading…
Add table
Add a link
Reference in a new issue