diff options
| author | Kujtim Hoxha <[email protected]> | 2025-03-23 19:19:08 +0100 |
|---|---|---|
| committer | Kujtim Hoxha <[email protected]> | 2025-03-23 19:19:08 +0100 |
| commit | 8daa6e774a6e02698c90392e7b2008542f789594 (patch) | |
| tree | 8053fc1a4de87fc8dc3fd6e776577b0221c02ac5 /internal | |
| parent | 796bbf4d66c0fc70e750c7f582019cb9a7298e59 (diff) | |
| download | opencode-8daa6e774a6e02698c90392e7b2008542f789594.tar.gz opencode-8daa6e774a6e02698c90392e7b2008542f789594.zip | |
add initial stuff
Diffstat (limited to 'internal')
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)) +} |
