add initial message handling

Kujtim Hoxha created

Change summary

internal/db/messages.sql.go                  |  34 +
internal/db/migrations/000001_initial.up.sql |   2 
internal/db/models.go                        |  14 
internal/db/sql/messages.sql                 |   5 
internal/message/content.go                  |  14 
internal/message/message.go                  |  14 
internal/tui/components/chat/chat.go         | 113 +++++
internal/tui/components/chat/editor.go       |  65 +-
internal/tui/components/chat/messages.go     | 339 ++++++++++++++++
internal/tui/components/chat/sidebar.go      | 133 +-----
internal/tui/components/core/button.go       | 287 --------------
internal/tui/layout/split.go                 |  24 +
internal/tui/page/chat.go                    |  92 ++++
internal/tui/styles/markdown.go              | 446 +++++++++++++++++++++
14 files changed, 1,124 insertions(+), 458 deletions(-)

Detailed changes

internal/db/messages.sql.go 🔗

@@ -7,6 +7,7 @@ package db
 
 import (
 	"context"
+	"database/sql"
 )
 
 const createMessage = `-- name: CreateMessage :one
@@ -15,19 +16,21 @@ INSERT INTO messages (
     session_id,
     role,
     parts,
+    model,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
-RETURNING id, session_id, role, parts, created_at, updated_at
+RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
 `
 
 type CreateMessageParams struct {
-	ID        string `json:"id"`
-	SessionID string `json:"session_id"`
-	Role      string `json:"role"`
-	Parts     string `json:"parts"`
+	ID        string         `json:"id"`
+	SessionID string         `json:"session_id"`
+	Role      string         `json:"role"`
+	Parts     string         `json:"parts"`
+	Model     sql.NullString `json:"model"`
 }
 
 func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@@ -36,6 +39,7 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		arg.SessionID,
 		arg.Role,
 		arg.Parts,
+		arg.Model,
 	)
 	var i Message
 	err := row.Scan(
@@ -43,8 +47,10 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
 		&i.SessionID,
 		&i.Role,
 		&i.Parts,
+		&i.Model,
 		&i.CreatedAt,
 		&i.UpdatedAt,
+		&i.FinishedAt,
 	)
 	return i, err
 }
@@ -70,7 +76,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
 }
 
 const getMessage = `-- name: GetMessage :one
-SELECT id, session_id, role, parts, created_at, updated_at
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
 FROM messages
 WHERE id = ? LIMIT 1
 `
@@ -83,14 +89,16 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
 		&i.SessionID,
 		&i.Role,
 		&i.Parts,
+		&i.Model,
 		&i.CreatedAt,
 		&i.UpdatedAt,
+		&i.FinishedAt,
 	)
 	return i, err
 }
 
 const listMessagesBySession = `-- name: ListMessagesBySession :many
-SELECT id, session_id, role, parts, created_at, updated_at
+SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at
 FROM messages
 WHERE session_id = ?
 ORDER BY created_at ASC
@@ -110,8 +118,10 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
 			&i.SessionID,
 			&i.Role,
 			&i.Parts,
+			&i.Model,
 			&i.CreatedAt,
 			&i.UpdatedAt,
+			&i.FinishedAt,
 		); err != nil {
 			return nil, err
 		}
@@ -130,16 +140,18 @@ const updateMessage = `-- name: UpdateMessage :exec
 UPDATE messages
 SET
     parts = ?,
+    finished_at = ?,
     updated_at = strftime('%s', 'now')
 WHERE id = ?
 `
 
 type UpdateMessageParams struct {
-	Parts string `json:"parts"`
-	ID    string `json:"id"`
+	Parts      string        `json:"parts"`
+	FinishedAt sql.NullInt64 `json:"finished_at"`
+	ID         string        `json:"id"`
 }
 
 func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
-	_, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.ID)
+	_, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.FinishedAt, arg.ID)
 	return err
 }

internal/db/migrations/000001_initial.up.sql 🔗

@@ -24,8 +24,10 @@ CREATE TABLE IF NOT EXISTS messages (
     session_id TEXT NOT NULL,
     role TEXT NOT NULL,
     parts TEXT NOT NULL default '[]',
+    model TEXT,
     created_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
     updated_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
+    finished_at INTEGER,  -- Unix timestamp in milliseconds
     FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
 );
 

internal/db/models.go 🔗

@@ -9,12 +9,14 @@ import (
 )
 
 type Message struct {
-	ID        string `json:"id"`
-	SessionID string `json:"session_id"`
-	Role      string `json:"role"`
-	Parts     string `json:"parts"`
-	CreatedAt int64  `json:"created_at"`
-	UpdatedAt int64  `json:"updated_at"`
+	ID         string         `json:"id"`
+	SessionID  string         `json:"session_id"`
+	Role       string         `json:"role"`
+	Parts      string         `json:"parts"`
+	Model      sql.NullString `json:"model"`
+	CreatedAt  int64          `json:"created_at"`
+	UpdatedAt  int64          `json:"updated_at"`
+	FinishedAt sql.NullInt64  `json:"finished_at"`
 }
 
 type Session struct {

internal/db/sql/messages.sql 🔗

@@ -15,10 +15,11 @@ INSERT INTO messages (
     session_id,
     role,
     parts,
+    model,
     created_at,
     updated_at
 ) VALUES (
-    ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
+    ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
 )
 RETURNING *;
 
@@ -26,9 +27,11 @@ RETURNING *;
 UPDATE messages
 SET
     parts = ?,
+    finished_at = ?,
     updated_at = strftime('%s', 'now')
 WHERE id = ?;
 
+
 -- name: DeleteMessage :exec
 DELETE FROM messages
 WHERE id = ?;

internal/message/content.go 🔗

@@ -2,6 +2,7 @@ package message
 
 import (
 	"encoding/base64"
+	"time"
 )
 
 type MessageRole string
@@ -64,6 +65,7 @@ type ToolCall struct {
 	Name     string `json:"name"`
 	Input    string `json:"input"`
 	Type     string `json:"type"`
+	Metadata any    `json:"metadata"`
 	Finished bool   `json:"finished"`
 }
 
@@ -80,6 +82,7 @@ func (ToolResult) isPart() {}
 
 type Finish struct {
 	Reason string `json:"reason"`
+	Time   int64  `json:"time"`
 }
 
 func (Finish) isPart() {}
@@ -161,6 +164,15 @@ func (m *Message) IsFinished() bool {
 	return false
 }
 
+func (m *Message) FinishPart() *Finish {
+	for _, part := range m.Parts {
+		if c, ok := part.(Finish); ok {
+			return &c
+		}
+	}
+	return nil
+}
+
 func (m *Message) FinishReason() string {
 	for _, part := range m.Parts {
 		if c, ok := part.(Finish); ok {
@@ -232,7 +244,7 @@ func (m *Message) SetToolResults(tr []ToolResult) {
 }
 
 func (m *Message) AddFinish(reason string) {
-	m.Parts = append(m.Parts, Finish{Reason: reason})
+	m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
 }
 
 func (m *Message) AddImageURL(url, detail string) {

internal/message/message.go 🔗

@@ -2,17 +2,20 @@ package message
 
 import (
 	"context"
+	"database/sql"
 	"encoding/json"
 	"fmt"
 
 	"github.com/google/uuid"
 	"github.com/kujtimiihoxha/termai/internal/db"
+	"github.com/kujtimiihoxha/termai/internal/llm/models"
 	"github.com/kujtimiihoxha/termai/internal/pubsub"
 )
 
 type CreateMessageParams struct {
 	Role  MessageRole
 	Parts []ContentPart
+	Model models.ModelID
 }
 
 type Service interface {
@@ -68,6 +71,7 @@ func (s *service) Create(sessionID string, params CreateMessageParams) (Message,
 		SessionID: sessionID,
 		Role:      string(params.Role),
 		Parts:     string(partsJSON),
+		Model:     sql.NullString{String: string(params.Model), Valid: true},
 	})
 	if err != nil {
 		return Message{}, err
@@ -101,9 +105,15 @@ func (s *service) Update(message Message) error {
 	if err != nil {
 		return err
 	}
+	finishedAt := sql.NullInt64{}
+	if f := message.FinishPart(); f != nil {
+		finishedAt.Int64 = f.Time
+		finishedAt.Valid = true
+	}
 	err = s.q.UpdateMessage(s.ctx, db.UpdateMessageParams{
-		ID:    message.ID,
-		Parts: string(parts),
+		ID:         message.ID,
+		Parts:      string(parts),
+		FinishedAt: finishedAt,
 	})
 	if err != nil {
 		return err

internal/tui/components/chat/chat.go 🔗

@@ -0,0 +1,113 @@
+package chat
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/kujtimiihoxha/termai/internal/config"
+	"github.com/kujtimiihoxha/termai/internal/session"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+	"github.com/kujtimiihoxha/termai/internal/version"
+)
+
+type SendMsg struct {
+	Text string
+}
+
+type SessionSelectedMsg = session.Session
+
+type SessionClearedMsg struct{}
+
+type AgentWorkingMsg bool
+
+type EditorFocusMsg bool
+
+func lspsConfigured(width int) string {
+	cfg := config.Get()
+	title := "LSP Configuration"
+	title = ansi.Truncate(title, width, "…")
+
+	lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
+
+	var lspViews []string
+	for name, lsp := range cfg.LSP {
+		lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
+			fmt.Sprintf("• %s", name),
+		)
+		cmd := lsp.Command
+		cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
+		lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
+			fmt.Sprintf(" (%s)", cmd),
+		)
+		lspViews = append(lspViews,
+			styles.BaseStyle.
+				Width(width).
+				Render(
+					lipgloss.JoinHorizontal(
+						lipgloss.Left,
+						lspName,
+						lspPath,
+					),
+				),
+		)
+
+	}
+	return styles.BaseStyle.
+		Width(width).
+		Render(
+			lipgloss.JoinVertical(
+				lipgloss.Left,
+				lsps,
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					lspViews...,
+				),
+			),
+		)
+}
+
+func logo(width int) string {
+	logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+
+	version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
+
+	return styles.BaseStyle.
+		Bold(true).
+		Width(width).
+		Render(
+			lipgloss.JoinHorizontal(
+				lipgloss.Left,
+				logo,
+				" ",
+				version,
+			),
+		)
+}
+
+func repo(width int) string {
+	repo := "https://github.com/kujtimiihoxha/opencode"
+	return styles.BaseStyle.
+		Foreground(styles.ForgroundDim).
+		Width(width).
+		Render(repo)
+}
+
+func cwd(width int) string {
+	cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
+	return styles.BaseStyle.
+		Foreground(styles.ForgroundDim).
+		Width(width).
+		Render(cwd)
+}
+
+func header(width int) string {
+	header := lipgloss.JoinVertical(
+		lipgloss.Top,
+		logo(width),
+		repo(width),
+		"",
+		cwd(width),
+	)
+	return header
+}

internal/tui/components/chat/editor.go 🔗

@@ -7,10 +7,12 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type editorCmp struct {
-	textarea textarea.Model
+	textarea     textarea.Model
+	agentWorking bool
 }
 
 type focusedEditorKeyMaps struct {
@@ -49,39 +51,51 @@ func (m *editorCmp) Init() tea.Cmd {
 	return textarea.Blink
 }
 
+func (m *editorCmp) send() tea.Cmd {
+	if m.agentWorking {
+		return util.ReportWarn("Agent is working, please wait...")
+	}
+
+	value := m.textarea.Value()
+	m.textarea.Reset()
+	m.textarea.Blur()
+	if value == "" {
+		return nil
+	}
+	return tea.Batch(
+		util.CmdHandler(SendMsg{
+			Text: value,
+		}),
+		util.CmdHandler(AgentWorkingMsg(true)),
+		util.CmdHandler(EditorFocusMsg(false)),
+	)
+}
+
 func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
-	if m.textarea.Focused() {
-		switch msg := msg.(type) {
-		case tea.KeyMsg:
-			if key.Matches(msg, focusedKeyMaps.Send) {
-				// TODO: send message
-				m.textarea.Reset()
-				m.textarea.Blur()
-				return m, nil
-			}
-			if key.Matches(msg, focusedKeyMaps.Blur) {
-				m.textarea.Blur()
-				return m, nil
-			}
-		}
-		m.textarea, cmd = m.textarea.Update(msg)
-		return m, cmd
-	}
 	switch msg := msg.(type) {
+	case AgentWorkingMsg:
+		m.agentWorking = bool(msg)
 	case tea.KeyMsg:
+		if key.Matches(msg, focusedKeyMaps.Send) {
+			return m, m.send()
+		}
 		if key.Matches(msg, bluredKeyMaps.Send) {
-			// TODO: send message
-			m.textarea.Reset()
-			return m, nil
+			return m, m.send()
+		}
+		if key.Matches(msg, focusedKeyMaps.Blur) {
+			m.textarea.Blur()
+			return m, util.CmdHandler(EditorFocusMsg(false))
 		}
 		if key.Matches(msg, bluredKeyMaps.Focus) {
-			m.textarea.Focus()
-			return m, textarea.Blink
+			if !m.textarea.Focused() {
+				m.textarea.Focus()
+				return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+			}
 		}
 	}
-
-	return m, nil
+	m.textarea, cmd = m.textarea.Update(msg)
+	return m, cmd
 }
 
 func (m *editorCmp) View() string {
@@ -122,6 +136,7 @@ func NewEditorCmp() tea.Model {
 	ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
 	ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
 	ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+	ti.CharLimit = -1
 	ti.Focus()
 	return &editorCmp{
 		textarea: ti,

internal/tui/components/chat/messages.go 🔗

@@ -1,21 +1,344 @@
 package chat
 
-import tea "github.com/charmbracelet/bubbletea"
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
 
-type messagesCmp struct{}
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/kujtimiihoxha/termai/internal/app"
+	"github.com/kujtimiihoxha/termai/internal/message"
+	"github.com/kujtimiihoxha/termai/internal/pubsub"
+	"github.com/kujtimiihoxha/termai/internal/session"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
+)
+
+type uiMessage struct {
+	position int
+	height   int
+	content  string
+}
+
+type messagesCmp struct {
+	app           *app.App
+	width, height int
+	writingMode   bool
+	viewport      viewport.Model
+	session       session.Session
+	messages      []message.Message
+	uiMessages    []uiMessage
+	currentIndex  int
+	renderer      *glamour.TermRenderer
+	focusRenderer *glamour.TermRenderer
+	cachedContent map[string]string
+}
 
 func (m *messagesCmp) Init() tea.Cmd {
-	return nil
+	return m.viewport.Init()
+}
+
+var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
+
+func hexToBgSGR(hex string) (string, error) {
+	hex = strings.TrimPrefix(hex, "#")
+	if len(hex) != 6 {
+		return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits")
+	}
+
+	// Parse RGB components in one block
+	rgb := make([]uint64, 3)
+	for i := 0; i < 3; i++ {
+		val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8)
+		if err != nil {
+			return "", err
+		}
+		rgb[i] = val
+	}
+
+	return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil
+}
+
+func forceReplaceBackgroundColors(input string, newBg string) string {
+	return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
+		// Extract content between "\x1b[" and "m"
+		content := seq[2 : len(seq)-1]
+		tokens := strings.Split(content, ";")
+		var newTokens []string
+
+		// Skip background color tokens
+		for i := 0; i < len(tokens); i++ {
+			if tokens[i] == "" {
+				continue
+			}
+
+			val, err := strconv.Atoi(tokens[i])
+			if err != nil {
+				newTokens = append(newTokens, tokens[i])
+				continue
+			}
+
+			// Skip background color tokens
+			if val == 48 {
+				// Skip "48;5;N" or "48;2;R;G;B" sequences
+				if i+1 < len(tokens) {
+					if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
+						switch nextVal {
+						case 5:
+							i += 2 // Skip "5" and color index
+						case 2:
+							i += 4 // Skip "2" and RGB components
+						}
+					}
+				}
+			} else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
+				// Keep non-background tokens
+				newTokens = append(newTokens, tokens[i])
+			}
+		}
+
+		// Add new background if provided
+		if newBg != "" {
+			newTokens = append(newTokens, strings.Split(newBg, ";")...)
+		}
+
+		if len(newTokens) == 0 {
+			return ""
+		}
+
+		return "\x1b[" + strings.Join(newTokens, ";") + "m"
+	})
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case EditorFocusMsg:
+		m.writingMode = bool(msg)
+	case SessionSelectedMsg:
+		if msg.ID != m.session.ID {
+			cmd := m.SetSession(msg)
+			return m, cmd
+		}
+		return m, nil
+	case pubsub.Event[message.Message]:
+		if msg.Type == pubsub.CreatedEvent {
+			if msg.Payload.SessionID == m.session.ID {
+				// check if message exists
+				for _, v := range m.messages {
+					if v.ID == msg.Payload.ID {
+						return m, nil
+					}
+				}
+
+				m.messages = append(m.messages, msg.Payload)
+				m.renderView()
+				m.viewport.GotoBottom()
+			}
+			for _, v := range m.messages {
+				for _, c := range v.ToolCalls() {
+					// the message is being added to the session of a tool called
+					if c.ID == msg.Payload.SessionID {
+						m.renderView()
+						m.viewport.GotoBottom()
+					}
+				}
+			}
+		} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
+			for i, v := range m.messages {
+				if v.ID == msg.Payload.ID {
+					m.messages[i] = msg.Payload
+					delete(m.cachedContent, msg.Payload.ID)
+					m.renderView()
+					if i == len(m.messages)-1 {
+						m.viewport.GotoBottom()
+					}
+					break
+				}
+			}
+		}
+	}
+	u, cmd := m.viewport.Update(msg)
+	m.viewport = u
+	return m, cmd
 }
 
-func (m *messagesCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
-	return m, nil
+func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
+	if v, ok := m.cachedContent[msg.ID]; ok {
+		return v
+	}
+	style := styles.BaseStyle.
+		Width(m.width).
+		BorderLeft(true).
+		Foreground(styles.ForgroundDim).
+		BorderForeground(styles.ForgroundDim).
+		BorderStyle(lipgloss.ThickBorder())
+
+	renderer := m.renderer
+	if inx == m.currentIndex {
+		style = style.
+			Foreground(styles.Forground).
+			BorderForeground(styles.Blue).
+			BorderStyle(lipgloss.ThickBorder())
+		renderer = m.focusRenderer
+	}
+	c, _ := renderer.Render(msg.Content().String())
+	col, _ := hexToBgSGR(styles.Background.Dark)
+	rendered := style.Render(forceReplaceBackgroundColors(c, col))
+	m.cachedContent[msg.ID] = rendered
+	return rendered
+}
+
+func (m *messagesCmp) renderView() {
+	m.uiMessages = make([]uiMessage, 0)
+	pos := 0
+
+	for _, v := range m.messages {
+		content := ""
+		switch v.Role {
+		case message.User:
+			content = m.renderUserMessage(pos, v)
+		}
+		m.uiMessages = append(m.uiMessages, uiMessage{
+			position: pos,
+			height:   lipgloss.Height(content),
+			content:  content,
+		})
+		pos += lipgloss.Height(content) + 1 // + 1 for spacing
+	}
+
+	messages := make([]string, 0)
+	for _, v := range m.uiMessages {
+		messages = append(messages, v.content)
+	}
+	m.viewport.SetContent(
+		styles.BaseStyle.
+			Width(m.width).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Top,
+					messages...,
+				),
+			),
+	)
 }
 
 func (m *messagesCmp) View() string {
-	return "Messages"
+	if len(m.messages) == 0 {
+		content := styles.BaseStyle.
+			Width(m.width).
+			Height(m.height - 1).
+			Render(
+				m.initialScreen(),
+			)
+
+		return styles.BaseStyle.
+			Width(m.width).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Top,
+					content,
+					m.help(),
+				),
+			)
+	}
+
+	m.renderView()
+	return styles.BaseStyle.
+		Width(m.width).
+		Render(
+			lipgloss.JoinVertical(
+				lipgloss.Top,
+				m.viewport.View(),
+				m.help(),
+			),
+		)
+}
+
+func (m *messagesCmp) help() string {
+	text := ""
+	if m.writingMode {
+		text = lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
+			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
+		)
+	} else {
+		text = lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
+			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
+		)
+	}
+
+	return styles.BaseStyle.
+		Width(m.width).
+		Render(text)
+}
+
+func (m *messagesCmp) initialScreen() string {
+	return styles.BaseStyle.Width(m.width).Render(
+		lipgloss.JoinVertical(
+			lipgloss.Top,
+			header(m.width),
+			"",
+			lspsConfigured(m.width),
+		),
+	)
+}
+
+func (m *messagesCmp) SetSize(width, height int) {
+	m.width = width
+	m.height = height
+	m.viewport.Width = width
+	m.viewport.Height = height - 1
+	focusRenderer, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(styles.MarkdownTheme(true)),
+		glamour.WithWordWrap(width-1),
+	)
+	renderer, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(styles.MarkdownTheme(false)),
+		glamour.WithWordWrap(width-1),
+	)
+	m.focusRenderer = focusRenderer
+	m.renderer = renderer
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
+	m.session = session
+	messages, err := m.app.Messages.List(session.ID)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	m.messages = messages
+	m.messages = append(m.messages, m.messages[0])
+	return nil
 }
 
-func NewMessagesCmp() tea.Model {
-	return &messagesCmp{}
+func NewMessagesCmp(app *app.App) tea.Model {
+	focusRenderer, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(styles.MarkdownTheme(true)),
+		glamour.WithWordWrap(80),
+	)
+	renderer, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(styles.MarkdownTheme(false)),
+		glamour.WithWordWrap(80),
+	)
+	return &messagesCmp{
+		app:           app,
+		writingMode:   true,
+		cachedContent: make(map[string]string),
+		viewport:      viewport.New(0, 0),
+		focusRenderer: focusRenderer,
+		renderer:      renderer,
+	}
 }

internal/tui/components/chat/sidebar.go 🔗

@@ -5,40 +5,43 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/kujtimiihoxha/termai/internal/config"
+	"github.com/kujtimiihoxha/termai/internal/session"
 	"github.com/kujtimiihoxha/termai/internal/tui/styles"
-	"github.com/kujtimiihoxha/termai/internal/version"
 )
 
 type sidebarCmp struct {
 	width, height int
+	session       session.Session
 }
 
 func (m *sidebarCmp) Init() tea.Cmd {
 	return nil
 }
 
-func (m *sidebarCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
 func (m *sidebarCmp) View() string {
-	return styles.BaseStyle.Width(m.width).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Top,
-			m.header(),
-			" ",
-			m.session(),
-			" ",
-			m.modifiedFiles(),
-			" ",
-			m.lspsConfigured(),
-		),
-	)
+	return styles.BaseStyle.
+		Width(m.width).
+		Height(m.height - 1).
+		Render(
+			lipgloss.JoinVertical(
+				lipgloss.Top,
+				header(m.width),
+				" ",
+				m.sessionSection(),
+				" ",
+				m.modifiedFiles(),
+				" ",
+				lspsConfigured(m.width),
+			),
+		)
 }
 
-func (m *sidebarCmp) session() string {
-	sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Render("Session")
+func (m *sidebarCmp) sessionSection() string {
+	sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
 	sessionValue := styles.BaseStyle.
 		Foreground(styles.Forground).
 		Width(m.width - lipgloss.Width(sessionKey)).
@@ -53,11 +56,11 @@ func (m *sidebarCmp) session() string {
 func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
 	stats := ""
 	if additions > 0 && removals > 0 {
-		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions and  %d removals", additions, removals))
+		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and  %d removals", additions, removals))
 	} else if additions > 0 {
-		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions", additions))
+		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
 	} else if removals > 0 {
-		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d removals", removals))
+		stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
 	}
 	filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
 
@@ -67,60 +70,13 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri
 			lipgloss.JoinHorizontal(
 				lipgloss.Left,
 				filePathStr,
-				" ",
 				stats,
 			),
 		)
 }
 
-func (m *sidebarCmp) lspsConfigured() string {
-	lsps := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("LSP Configuration:")
-	lspsConfigured := []struct {
-		name string
-		path string
-	}{
-		{"golsp", "path/to/lsp1"},
-		{"vtsls", "path/to/lsp2"},
-	}
-
-	var lspViews []string
-	for _, lsp := range lspsConfigured {
-		lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
-			fmt.Sprintf("• %s", lsp.name),
-		)
-		lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
-			fmt.Sprintf("(%s)", lsp.path),
-		)
-		lspViews = append(lspViews,
-			styles.BaseStyle.
-				Width(m.width).
-				Render(
-					lipgloss.JoinHorizontal(
-						lipgloss.Left,
-						lspName,
-						" ",
-						lspPath,
-					),
-				),
-		)
-
-	}
-	return styles.BaseStyle.
-		Width(m.width).
-		Render(
-			lipgloss.JoinVertical(
-				lipgloss.Left,
-				lsps,
-				lipgloss.JoinVertical(
-					lipgloss.Left,
-					lspViews...,
-				),
-			),
-		)
-}
-
 func (m *sidebarCmp) modifiedFiles() string {
-	modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("Modified Files:")
+	modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
 	files := []struct {
 		path      string
 		additions int
@@ -149,41 +105,6 @@ func (m *sidebarCmp) modifiedFiles() string {
 		)
 }
 
-func (m *sidebarCmp) logo() string {
-	logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
-
-	version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
-
-	return styles.BaseStyle.
-		Bold(true).
-		Width(m.width).
-		Render(
-			lipgloss.JoinHorizontal(
-				lipgloss.Left,
-				logo,
-				" ",
-				version,
-			),
-		)
-}
-
-func (m *sidebarCmp) header() string {
-	header := lipgloss.JoinVertical(
-		lipgloss.Top,
-		m.logo(),
-		m.cwd(),
-	)
-	return header
-}
-
-func (m *sidebarCmp) cwd() string {
-	cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
-	return styles.BaseStyle.
-		Foreground(styles.ForgroundDim).
-		Width(m.width).
-		Render(cwd)
-}
-
 func (m *sidebarCmp) SetSize(width, height int) {
 	m.width = width
 	m.height = height
@@ -193,6 +114,8 @@ func (m *sidebarCmp) GetSize() (int, int) {
 	return m.width, m.height
 }
 
-func NewSidebarCmp() tea.Model {
-	return &sidebarCmp{}
+func NewSidebarCmp(session session.Session) tea.Model {
+	return &sidebarCmp{
+		session: session,
+	}
 }

internal/tui/components/core/button.go 🔗

@@ -1,287 +0,0 @@
-package core
-
-import (
-	"github.com/charmbracelet/bubbles/key"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-// ButtonKeyMap defines key bindings for the button component
-type ButtonKeyMap struct {
-	Enter key.Binding
-}
-
-// DefaultButtonKeyMap returns default key bindings for the button
-func DefaultButtonKeyMap() ButtonKeyMap {
-	return ButtonKeyMap{
-		Enter: key.NewBinding(
-			key.WithKeys("enter"),
-			key.WithHelp("enter", "select"),
-		),
-	}
-}
-
-// ShortHelp returns keybinding help
-func (k ButtonKeyMap) ShortHelp() []key.Binding {
-	return []key.Binding{k.Enter}
-}
-
-// FullHelp returns full help info for keybindings
-func (k ButtonKeyMap) FullHelp() [][]key.Binding {
-	return [][]key.Binding{
-		{k.Enter},
-	}
-}
-
-// ButtonState represents the state of a button
-type ButtonState int
-
-const (
-	// ButtonNormal is the default state
-	ButtonNormal ButtonState = iota
-	// ButtonHovered is when the button is focused/hovered
-	ButtonHovered
-	// ButtonPressed is when the button is being pressed
-	ButtonPressed
-	// ButtonDisabled is when the button is disabled
-	ButtonDisabled
-)
-
-// ButtonVariant defines the visual style variant of a button
-type ButtonVariant int
-
-const (
-	// ButtonPrimary uses primary color styling
-	ButtonPrimary ButtonVariant = iota
-	// ButtonSecondary uses secondary color styling
-	ButtonSecondary
-	// ButtonDanger uses danger/error color styling
-	ButtonDanger
-	// ButtonWarning uses warning color styling
-	ButtonWarning
-	// ButtonNeutral uses neutral color styling
-	ButtonNeutral
-)
-
-// ButtonMsg is sent when a button is clicked
-type ButtonMsg struct {
-	ID      string
-	Payload any
-}
-
-// ButtonCmp represents a clickable button component
-type ButtonCmp struct {
-	id         string
-	label      string
-	width      int
-	height     int
-	state      ButtonState
-	variant    ButtonVariant
-	keyMap     ButtonKeyMap
-	payload    any
-	style      lipgloss.Style
-	hoverStyle lipgloss.Style
-}
-
-// NewButtonCmp creates a new button component
-func NewButtonCmp(id, label string) *ButtonCmp {
-	b := &ButtonCmp{
-		id:      id,
-		label:   label,
-		state:   ButtonNormal,
-		variant: ButtonPrimary,
-		keyMap:  DefaultButtonKeyMap(),
-		width:   len(label) + 4, // add some padding
-		height:  1,
-	}
-	b.updateStyles()
-	return b
-}
-
-// WithVariant sets the button variant
-func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
-	b.variant = variant
-	b.updateStyles()
-	return b
-}
-
-// WithPayload sets the payload sent with button events
-func (b *ButtonCmp) WithPayload(payload any) *ButtonCmp {
-	b.payload = payload
-	return b
-}
-
-// WithWidth sets a custom width
-func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
-	b.width = width
-	b.updateStyles()
-	return b
-}
-
-// updateStyles recalculates styles based on current state and variant
-func (b *ButtonCmp) updateStyles() {
-	// Base styles
-	b.style = styles.Regular.
-		Padding(0, 1).
-		Width(b.width).
-		Align(lipgloss.Center).
-		BorderStyle(lipgloss.RoundedBorder())
-
-	b.hoverStyle = b.style.
-		Bold(true)
-
-	// Variant-specific styling
-	switch b.variant {
-	case ButtonPrimary:
-		b.style = b.style.
-			Foreground(styles.Base).
-			Background(styles.Primary).
-			BorderForeground(styles.Primary)
-
-		b.hoverStyle = b.hoverStyle.
-			Foreground(styles.Base).
-			Background(styles.Blue).
-			BorderForeground(styles.Blue)
-
-	case ButtonSecondary:
-		b.style = b.style.
-			Foreground(styles.Base).
-			Background(styles.Secondary).
-			BorderForeground(styles.Secondary)
-
-		b.hoverStyle = b.hoverStyle.
-			Foreground(styles.Base).
-			Background(styles.Mauve).
-			BorderForeground(styles.Mauve)
-
-	case ButtonDanger:
-		b.style = b.style.
-			Foreground(styles.Base).
-			Background(styles.Error).
-			BorderForeground(styles.Error)
-
-		b.hoverStyle = b.hoverStyle.
-			Foreground(styles.Base).
-			Background(styles.Red).
-			BorderForeground(styles.Red)
-
-	case ButtonWarning:
-		b.style = b.style.
-			Foreground(styles.Text).
-			Background(styles.Warning).
-			BorderForeground(styles.Warning)
-
-		b.hoverStyle = b.hoverStyle.
-			Foreground(styles.Text).
-			Background(styles.Peach).
-			BorderForeground(styles.Peach)
-
-	case ButtonNeutral:
-		b.style = b.style.
-			Foreground(styles.Text).
-			Background(styles.Grey).
-			BorderForeground(styles.Grey)
-
-		b.hoverStyle = b.hoverStyle.
-			Foreground(styles.Text).
-			Background(styles.DarkGrey).
-			BorderForeground(styles.DarkGrey)
-	}
-
-	// Disabled style override
-	if b.state == ButtonDisabled {
-		b.style = b.style.
-			Foreground(styles.SubText0).
-			Background(styles.LightGrey).
-			BorderForeground(styles.LightGrey)
-	}
-}
-
-// SetSize sets the button size
-func (b *ButtonCmp) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.updateStyles()
-}
-
-// Focus sets the button to focused state
-func (b *ButtonCmp) Focus() tea.Cmd {
-	if b.state != ButtonDisabled {
-		b.state = ButtonHovered
-	}
-	return nil
-}
-
-// Blur sets the button to normal state
-func (b *ButtonCmp) Blur() tea.Cmd {
-	if b.state != ButtonDisabled {
-		b.state = ButtonNormal
-	}
-	return nil
-}
-
-// Disable sets the button to disabled state
-func (b *ButtonCmp) Disable() {
-	b.state = ButtonDisabled
-	b.updateStyles()
-}
-
-// Enable enables the button if disabled
-func (b *ButtonCmp) Enable() {
-	if b.state == ButtonDisabled {
-		b.state = ButtonNormal
-		b.updateStyles()
-	}
-}
-
-// IsDisabled returns whether the button is disabled
-func (b *ButtonCmp) IsDisabled() bool {
-	return b.state == ButtonDisabled
-}
-
-// IsFocused returns whether the button is focused
-func (b *ButtonCmp) IsFocused() bool {
-	return b.state == ButtonHovered
-}
-
-// Init initializes the button
-func (b *ButtonCmp) Init() tea.Cmd {
-	return nil
-}
-
-// Update handles messages and user input
-func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	// Skip updates if disabled
-	if b.state == ButtonDisabled {
-		return b, nil
-	}
-
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		// Handle key presses when focused
-		if b.state == ButtonHovered {
-			switch {
-			case key.Matches(msg, b.keyMap.Enter):
-				b.state = ButtonPressed
-				return b, func() tea.Msg {
-					return ButtonMsg{
-						ID:      b.id,
-						Payload: b.payload,
-					}
-				}
-			}
-		}
-	}
-
-	return b, nil
-}
-
-// View renders the button
-func (b *ButtonCmp) View() string {
-	if b.state == ButtonHovered || b.state == ButtonPressed {
-		return b.hoverStyle.Render(b.label)
-	}
-	return b.style.Render(b.label)
-}
-

internal/tui/layout/split.go 🔗

@@ -10,6 +10,9 @@ import (
 type SplitPaneLayout interface {
 	tea.Model
 	Sizeable
+	SetLeftPanel(panel Container)
+	SetRightPanel(panel Container)
+	SetBottomPanel(panel Container)
 }
 
 type splitPaneLayout struct {
@@ -160,6 +163,27 @@ func (s *splitPaneLayout) GetSize() (int, int) {
 	return s.width, s.height
 }
 
+func (s *splitPaneLayout) SetLeftPanel(panel Container) {
+	s.leftPanel = panel
+	if s.width > 0 && s.height > 0 {
+		s.SetSize(s.width, s.height)
+	}
+}
+
+func (s *splitPaneLayout) SetRightPanel(panel Container) {
+	s.rightPanel = panel
+	if s.width > 0 && s.height > 0 {
+		s.SetSize(s.width, s.height)
+	}
+}
+
+func (s *splitPaneLayout) SetBottomPanel(panel Container) {
+	s.bottomPanel = panel
+	if s.width > 0 && s.height > 0 {
+		s.SetSize(s.width, s.height)
+	}
+}
+
 func (s *splitPaneLayout) BindingKeys() []key.Binding {
 	keys := []key.Binding{}
 	if s.leftPanel != nil {

internal/tui/page/chat.go 🔗

@@ -3,28 +3,100 @@ package page
 import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/kujtimiihoxha/termai/internal/app"
+	"github.com/kujtimiihoxha/termai/internal/message"
+	"github.com/kujtimiihoxha/termai/internal/session"
 	"github.com/kujtimiihoxha/termai/internal/tui/components/chat"
 	"github.com/kujtimiihoxha/termai/internal/tui/layout"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 var ChatPage PageID = "chat"
 
-func NewChatPage(app *app.App) tea.Model {
-	messagesContainer := layout.NewContainer(
-		chat.NewMessagesCmp(),
+type chatPage struct {
+	app     *app.App
+	layout  layout.SplitPaneLayout
+	session session.Session
+}
+
+func (p *chatPage) Init() tea.Cmd {
+	return p.layout.Init()
+}
+
+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		p.layout.SetSize(msg.Width, msg.Height)
+	case chat.SendMsg:
+		cmd := p.sendMessage(msg.Text)
+		if cmd != nil {
+			return p, cmd
+		}
+	}
+	u, cmd := p.layout.Update(msg)
+	p.layout = u.(layout.SplitPaneLayout)
+	if cmd != nil {
+		return p, cmd
+	}
+	return p, nil
+}
+
+func (p *chatPage) setSidebar() tea.Cmd {
+	sidebarContainer := layout.NewContainer(
+		chat.NewSidebarCmp(p.session),
 		layout.WithPadding(1, 1, 1, 1),
 	)
-	sidebarContainer := layout.NewContainer(
-		chat.NewSidebarCmp(),
+	p.layout.SetRightPanel(sidebarContainer)
+	width, height := p.layout.GetSize()
+	p.layout.SetSize(width, height)
+	return sidebarContainer.Init()
+}
+
+func (p *chatPage) sendMessage(text string) tea.Cmd {
+	var cmds []tea.Cmd
+	if p.session.ID == "" {
+		session, err := p.app.Sessions.Create("New Session")
+		if err != nil {
+			return util.ReportError(err)
+		}
+
+		p.session = session
+		cmd := p.setSidebar()
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+	}
+	// TODO: actually call agent
+	p.app.Messages.Create(p.session.ID, message.CreateMessageParams{
+		Role: message.User,
+		Parts: []message.ContentPart{
+			message.TextContent{
+				Text: text,
+			},
+		},
+	})
+	return tea.Batch(cmds...)
+}
+
+func (p *chatPage) View() string {
+	return p.layout.View()
+}
+
+func NewChatPage(app *app.App) tea.Model {
+	messagesContainer := layout.NewContainer(
+		chat.NewMessagesCmp(app),
 		layout.WithPadding(1, 1, 1, 1),
 	)
+
 	editorContainer := layout.NewContainer(
 		chat.NewEditorCmp(),
 		layout.WithBorder(true, false, false, false),
 	)
-	return layout.NewSplitPane(
-		layout.WithRightPanel(sidebarContainer),
-		layout.WithLeftPanel(messagesContainer),
-		layout.WithBottomPanel(editorContainer),
-	)
+	return &chatPage{
+		app: app,
+		layout: layout.NewSplitPane(
+			layout.WithLeftPanel(messagesContainer),
+			layout.WithBottomPanel(editorContainer),
+		),
+	}
 }

internal/tui/styles/markdown.go 🔗

@@ -36,12 +36,13 @@ var catppuccinDark = ansi.StyleConfig{
 			Italic: boolPtr(true),
 			Prefix: "┃ ",
 		},
-		Indent: uintPtr(1),
-		Margin: uintPtr(defaultMargin),
+		Indent:      uintPtr(1),
+		IndentToken: stringPtr(BaseStyle.Render(" ")),
 	},
 	List: ansi.StyleList{
 		LevelIndent: defaultMargin,
 		StyleBlock: ansi.StyleBlock{
+			IndentToken: stringPtr(BaseStyle.Render(" ")),
 			StylePrimitive: ansi.StylePrimitive{
 				Color: stringPtr(dark.Text().Hex),
 			},
@@ -496,3 +497,444 @@ var catppuccinLight = ansi.StyleConfig{
 		Color:       stringPtr(light.Sapphire().Hex),
 	},
 }
+
+func MarkdownTheme(focused bool) ansi.StyleConfig {
+	if !focused {
+		return ASCIIStyleConfig
+	} else {
+		return DraculaStyleConfig
+	}
+}
+
+const (
+	defaultListIndent      = 2
+	defaultListLevelIndent = 4
+)
+
+var ASCIIStyleConfig = ansi.StyleConfig{
+	Document: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+		Indent:      uintPtr(1),
+		IndentToken: stringPtr(BaseStyle.Render(" ")),
+	},
+	BlockQuote: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+		Indent:      uintPtr(1),
+		IndentToken: stringPtr("| "),
+	},
+	Paragraph: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	List: ansi.StyleList{
+		StyleBlock: ansi.StyleBlock{
+			IndentToken: stringPtr(BaseStyle.Render(" ")),
+			StylePrimitive: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+		},
+		LevelIndent: defaultListLevelIndent,
+	},
+	Heading: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			BlockSuffix:     "\n",
+		},
+	},
+	H1: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "# ",
+		},
+	},
+	H2: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "## ",
+		},
+	},
+	H3: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "### ",
+		},
+	},
+	H4: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "#### ",
+		},
+	},
+	H5: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "##### ",
+		},
+	},
+	H6: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+			Prefix:          "###### ",
+		},
+	},
+	Strikethrough: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		BlockPrefix:     "~~",
+		BlockSuffix:     "~~",
+	},
+	Emph: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		BlockPrefix:     "*",
+		BlockSuffix:     "*",
+	},
+	Strong: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		BlockPrefix:     "**",
+		BlockSuffix:     "**",
+	},
+	HorizontalRule: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		Format:          "\n--------\n",
+	},
+	Item: ansi.StylePrimitive{
+		BlockPrefix:     "• ",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Enumeration: ansi.StylePrimitive{
+		BlockPrefix:     ". ",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Task: ansi.StyleTask{
+		Ticked:   "[x] ",
+		Unticked: "[ ] ",
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	ImageText: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		Format:          "Image: {{.text}} →",
+	},
+	Code: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BlockPrefix:     "`",
+			BlockSuffix:     "`",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	CodeBlock: ansi.StyleCodeBlock{
+		StyleBlock: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Margin: uintPtr(defaultMargin),
+		},
+	},
+	Table: ansi.StyleTable{
+		StyleBlock: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			IndentToken: stringPtr(BaseStyle.Render(" ")),
+		},
+		CenterSeparator: stringPtr("|"),
+		ColumnSeparator: stringPtr("|"),
+		RowSeparator:    stringPtr("-"),
+	},
+	DefinitionDescription: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+		BlockPrefix:     "\n* ",
+	},
+}
+
+var DraculaStyleConfig = ansi.StyleConfig{
+	Document: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Color:           stringPtr(Forground.Dark),
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+		Indent:      uintPtr(defaultMargin),
+		IndentToken: stringPtr(BaseStyle.Render(" ")),
+	},
+	BlockQuote: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Color:           stringPtr("#f1fa8c"),
+			Italic:          boolPtr(true),
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+		Indent:      uintPtr(defaultMargin),
+		IndentToken: stringPtr(BaseStyle.Render(" ")),
+	},
+	Paragraph: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	List: ansi.StyleList{
+		LevelIndent: defaultMargin,
+		StyleBlock: ansi.StyleBlock{
+			IndentToken: stringPtr(BaseStyle.Render(" ")),
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           stringPtr(Forground.Dark),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+		},
+	},
+	Heading: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			BlockSuffix:     "\n",
+			Color:           stringPtr("#bd93f9"),
+			Bold:            boolPtr(true),
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H1: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "# ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H2: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "## ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H3: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "### ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H4: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "#### ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H5: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "##### ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	H6: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Prefix:          "###### ",
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	Strikethrough: ansi.StylePrimitive{
+		CrossedOut:      boolPtr(true),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Emph: ansi.StylePrimitive{
+		Color:           stringPtr("#f1fa8c"),
+		Italic:          boolPtr(true),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Strong: ansi.StylePrimitive{
+		Bold:            boolPtr(true),
+		Color:           stringPtr("#ffb86c"),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	HorizontalRule: ansi.StylePrimitive{
+		Color:           stringPtr("#6272A4"),
+		Format:          "\n--------\n",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Item: ansi.StylePrimitive{
+		BlockPrefix:     "• ",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Enumeration: ansi.StylePrimitive{
+		BlockPrefix:     ". ",
+		Color:           stringPtr("#8be9fd"),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Task: ansi.StyleTask{
+		StylePrimitive: ansi.StylePrimitive{
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+		Ticked:   "[✓] ",
+		Unticked: "[ ] ",
+	},
+	Link: ansi.StylePrimitive{
+		Color:           stringPtr("#8be9fd"),
+		Underline:       boolPtr(true),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	LinkText: ansi.StylePrimitive{
+		Color:           stringPtr("#ff79c6"),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Image: ansi.StylePrimitive{
+		Color:           stringPtr("#8be9fd"),
+		Underline:       boolPtr(true),
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	ImageText: ansi.StylePrimitive{
+		Color:           stringPtr("#ff79c6"),
+		Format:          "Image: {{.text}} →",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	Code: ansi.StyleBlock{
+		StylePrimitive: ansi.StylePrimitive{
+			Color:           stringPtr("#50fa7b"),
+			BackgroundColor: stringPtr(Background.Dark),
+		},
+	},
+	Text: ansi.StylePrimitive{
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+	DefinitionList: ansi.StyleBlock{},
+	CodeBlock: ansi.StyleCodeBlock{
+		StyleBlock: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				Color:           stringPtr("#ffb86c"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Margin: uintPtr(defaultMargin),
+		},
+		Chroma: &ansi.Chroma{
+			NameOther: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Literal: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameException: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			LiteralDate: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Text: ansi.StylePrimitive{
+				Color:           stringPtr(Forground.Dark),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Error: ansi.StylePrimitive{
+				Color:           stringPtr("#f8f8f2"),
+				BackgroundColor: stringPtr("#ff5555"),
+			},
+			Comment: ansi.StylePrimitive{
+				Color:           stringPtr("#6272A4"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			CommentPreproc: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Keyword: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			KeywordReserved: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			KeywordNamespace: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			KeywordType: ansi.StylePrimitive{
+				Color:           stringPtr("#8be9fd"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Operator: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Punctuation: ansi.StylePrimitive{
+				Color:           stringPtr(Forground.Dark),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Name: ansi.StylePrimitive{
+				Color:           stringPtr("#8be9fd"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameBuiltin: ansi.StylePrimitive{
+				Color:           stringPtr("#8be9fd"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameTag: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameAttribute: ansi.StylePrimitive{
+				Color:           stringPtr("#50fa7b"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameClass: ansi.StylePrimitive{
+				Color:           stringPtr("#8be9fd"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameConstant: ansi.StylePrimitive{
+				Color:           stringPtr("#bd93f9"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameDecorator: ansi.StylePrimitive{
+				Color:           stringPtr("#50fa7b"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			NameFunction: ansi.StylePrimitive{
+				Color:           stringPtr("#50fa7b"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			LiteralNumber: ansi.StylePrimitive{
+				Color:           stringPtr("#6EEFC0"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			LiteralString: ansi.StylePrimitive{
+				Color:           stringPtr("#f1fa8c"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			LiteralStringEscape: ansi.StylePrimitive{
+				Color:           stringPtr("#ff79c6"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			GenericDeleted: ansi.StylePrimitive{
+				Color:           stringPtr("#ff5555"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			GenericEmph: ansi.StylePrimitive{
+				Color:           stringPtr("#f1fa8c"),
+				Italic:          boolPtr(true),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			GenericInserted: ansi.StylePrimitive{
+				Color:           stringPtr("#50fa7b"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			GenericStrong: ansi.StylePrimitive{
+				Color:           stringPtr("#ffb86c"),
+				Bold:            boolPtr(true),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			GenericSubheading: ansi.StylePrimitive{
+				Color:           stringPtr("#bd93f9"),
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			Background: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+		},
+	},
+	Table: ansi.StyleTable{
+		StyleBlock: ansi.StyleBlock{
+			StylePrimitive: ansi.StylePrimitive{
+				BackgroundColor: stringPtr(Background.Dark),
+			},
+			IndentToken: stringPtr(BaseStyle.Render(" ")),
+		},
+	},
+	DefinitionDescription: ansi.StylePrimitive{
+		BlockPrefix:     "\n* ",
+		BackgroundColor: stringPtr(Background.Dark),
+	},
+}