summaryrefslogtreecommitdiffhomepage
path: root/internal
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-03-23 19:19:08 +0100
committerKujtim Hoxha <[email protected]>2025-03-23 19:19:08 +0100
commit8daa6e774a6e02698c90392e7b2008542f789594 (patch)
tree8053fc1a4de87fc8dc3fd6e776577b0221c02ac5 /internal
parent796bbf4d66c0fc70e750c7f582019cb9a7298e59 (diff)
downloadopencode-8daa6e774a6e02698c90392e7b2008542f789594.tar.gz
opencode-8daa6e774a6e02698c90392e7b2008542f789594.zip
add initial stuff
Diffstat (limited to 'internal')
-rw-r--r--internal/app/services.go31
-rw-r--r--internal/db/connect.go93
-rw-r--r--internal/db/db.go128
-rw-r--r--internal/db/embed.go6
-rw-r--r--internal/db/migrations/000001_initial.down.sql4
-rw-r--r--internal/db/migrations/000001_initial.up.sql17
-rw-r--r--internal/db/models.go15
-rw-r--r--internal/db/querier.go20
-rw-r--r--internal/db/sessions.sql.go165
-rw-r--r--internal/db/sql/sessions.sql43
-rw-r--r--internal/pubsub/events.go6
-rw-r--r--internal/session/session.go116
-rw-r--r--internal/tui/components/core/dialog.go84
-rw-r--r--internal/tui/components/core/help.go22
-rw-r--r--internal/tui/components/dialog/quit.go111
-rw-r--r--internal/tui/components/repl/editor.go127
-rw-r--r--internal/tui/components/repl/messages.go15
-rw-r--r--internal/tui/components/repl/sessions.go161
-rw-r--r--internal/tui/components/repl/threads.go21
-rw-r--r--internal/tui/layout/bento.go20
-rw-r--r--internal/tui/layout/border.go7
-rw-r--r--internal/tui/layout/overlay.go205
-rw-r--r--internal/tui/layout/single.go23
-rw-r--r--internal/tui/page/repl.go9
-rw-r--r--internal/tui/styles/huh.go46
-rw-r--r--internal/tui/styles/styles.go3
-rw-r--r--internal/tui/tui.go80
-rw-r--r--internal/tui/util/util.go7
28 files changed, 1506 insertions, 79 deletions
diff --git a/internal/app/services.go b/internal/app/services.go
new file mode 100644
index 000000000..60b0e32f3
--- /dev/null
+++ b/internal/app/services.go
@@ -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,
+ }
+}
diff --git a/internal/db/connect.go b/internal/db/connect.go
new file mode 100644
index 000000000..50a30d0bc
--- /dev/null
+++ b/internal/db/connect.go
@@ -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
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 000000000..b97eb7108
--- /dev/null
+++ b/internal/db/db.go
@@ -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,
+ }
+}
diff --git a/internal/db/embed.go b/internal/db/embed.go
new file mode 100644
index 000000000..4afa6eafb
--- /dev/null
+++ b/internal/db/embed.go
@@ -0,0 +1,6 @@
+package db
+
+import "embed"
+
+//go:embed migrations/*.sql
+var FS embed.FS
diff --git a/internal/db/migrations/000001_initial.down.sql b/internal/db/migrations/000001_initial.down.sql
new file mode 100644
index 000000000..7324ddb6a
--- /dev/null
+++ b/internal/db/migrations/000001_initial.down.sql
@@ -0,0 +1,4 @@
+-- sqlfluff:dialect:sqlite
+DROP TRIGGER IF EXISTS update_sessions_updated_at;
+
+DROP TABLE IF EXISTS sessions;
diff --git a/internal/db/migrations/000001_initial.up.sql b/internal/db/migrations/000001_initial.up.sql
new file mode 100644
index 000000000..115b6145b
--- /dev/null
+++ b/internal/db/migrations/000001_initial.up.sql
@@ -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;
diff --git a/internal/db/models.go b/internal/db/models.go
new file mode 100644
index 000000000..834aaf4dc
--- /dev/null
+++ b/internal/db/models.go
@@ -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"`
+}
diff --git a/internal/db/querier.go b/internal/db/querier.go
new file mode 100644
index 000000000..ac2374cf8
--- /dev/null
+++ b/internal/db/querier.go
@@ -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)
diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go
new file mode 100644
index 000000000..3e0b6eea2
--- /dev/null
+++ b/internal/db/sessions.sql.go
@@ -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
+}
diff --git a/internal/db/sql/sessions.sql b/internal/db/sql/sessions.sql
new file mode 100644
index 000000000..e90e5e328
--- /dev/null
+++ b/internal/db/sql/sessions.sql
@@ -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 = ?;
diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go
index c560f6925..2fb0a7413 100644
--- a/internal/pubsub/events.go
+++ b/internal/pubsub/events.go
@@ -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
diff --git a/internal/session/session.go b/internal/session/session.go
new file mode 100644
index 000000000..60f35084f
--- /dev/null
+++ b/internal/session/session.go
@@ -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,
+ }
+}
diff --git a/internal/tui/components/core/dialog.go b/internal/tui/components/core/dialog.go
new file mode 100644
index 000000000..954e26e87
--- /dev/null
+++ b/internal/tui/components/core/dialog.go
@@ -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{}
+}
diff --git a/internal/tui/components/core/help.go b/internal/tui/components/core/help.go
index 9402d9362..4ef857c78 100644
--- a/internal/tui/components/core/help.go
+++ b/internal/tui/components/core/help.go
@@ -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
}
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
new file mode 100644
index 000000000..e73550ab5
--- /dev/null
+++ b/internal/tui/components/dialog/quit.go
@@ -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,
+ })
+}
diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go
index 8a04889ab..1de216bd0 100644
--- a/internal/tui/components/repl/editor.go
+++ b/internal/tui/components/repl/editor.go
@@ -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
+}
+
+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
+}
-func (i *editorCmp) Init() tea.Cmd {
+// 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",
+ }
+}
+
+// Focus implements EditorCmp.
+func (m *editorCmp) Focus() tea.Cmd {
+ m.focused = true
+ return m.editor.Tick()
+}
+
+// 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 (i *editorCmp) View() string {
- return "Editor"
+func (m *editorCmp) View() string {
+ return m.editor.View()
}
-func NewEditorCmp() tea.Model {
- return &editorCmp{}
+func NewEditorCmp(app *app.App) EditorCmp {
+ return &editorCmp{
+ app: app,
+ editor: vimtea.NewEditor(),
+ }
}
diff --git a/internal/tui/components/repl/messages.go b/internal/tui/components/repl/messages.go
index edef26502..cd3ddac71 100644
--- a/internal/tui/components/repl/messages.go
+++ b/internal/tui/components/repl/messages.go
@@ -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,
+ }
}
diff --git a/internal/tui/components/repl/sessions.go b/internal/tui/components/repl/sessions.go
new file mode 100644
index 000000000..aef9b993e
--- /dev/null
+++ b/internal/tui/components/repl/sessions.go
@@ -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,
+ }
+}
diff --git a/internal/tui/components/repl/threads.go b/internal/tui/components/repl/threads.go
deleted file mode 100644
index aa2bc080b..000000000
--- a/internal/tui/components/repl/threads.go
+++ /dev/null
@@ -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{}
-}
diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go
index 8d1f1d10f..5225db192 100644
--- a/internal/tui/layout/bento.go
+++ b/internal/tui/layout/bento.go
@@ -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 {
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
index d1b334b32..a3c80396f 100644
--- a/internal/tui/layout/border.go
+++ b/internal/tui/layout/border.go
@@ -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]
diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go
new file mode 100644
index 000000000..87cc80e68
--- /dev/null
+++ b/internal/tui/layout/overlay.go
@@ -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)
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
index 95513cc62..1e4d0881c 100644
--- a/internal/tui/layout/single.go
+++ b/internal/tui/layout/single.go
@@ -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
+ childWidth, childHeight := s.width, s.height
if s.bordered {
- s.width -= 2
- s.height -= 2
+ childWidth -= 2
+ childHeight -= 2
}
- childWidth, childHeight := s.width, s.height
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
+ }
+}
diff --git a/internal/tui/page/repl.go b/internal/tui/page/repl.go
index 829b6f545..dcb1ebcf7 100644
--- a/internal/tui/page/repl.go
+++ b/internal/tui/page/repl.go
@@ -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),
},
)
}
diff --git a/internal/tui/styles/huh.go b/internal/tui/styles/huh.go
new file mode 100644
index 000000000..d0e872758
--- /dev/null
+++ b/internal/tui/styles/huh.go
@@ -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
+}
diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go
index 457c555d0..807521d6f 100644
--- a/internal/tui/styles/styles.go
+++ b/internal/tui/styles/styles.go
@@ -122,4 +122,7 @@ var (
Primary = Blue
Secondary = Mauve
+
+ Warning = Peach
+ Error = Red
)
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 424f576c8..72e9174fa 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -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),
},
}
}
diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go
index fd1c4e818..fa3782843 100644
--- a/internal/tui/util/util.go
+++ b/internal/tui/util/util.go
@@ -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))
+}