small improvements

Kujtim Hoxha created

Change summary

.opencode.json                               | 11 ---
internal/app/app.go                          |  2 
internal/db/migrations/000001_initial.up.sql |  3 
internal/history/file.go                     | 76 +++++++++++++++++----
internal/tui/components/chat/editor.go       |  4 
internal/tui/components/chat/sidebar.go      |  5 +
internal/tui/components/core/status.go       | 56 +++++++++++++++
internal/tui/components/dialog/help.go       |  4 
internal/tui/layout/container.go             |  1 
internal/tui/page/chat.go                    | 22 ++++-
internal/tui/tui.go                          |  2 
11 files changed, 149 insertions(+), 37 deletions(-)

Detailed changes

.opencode.json 🔗

@@ -3,16 +3,5 @@
     "gopls": {
       "command": "gopls"
     }
-  },
-  "agents": {
-    "coder": {
-      "model": "gpt-4.1"
-    },
-    "task": {
-      "model": "gpt-4.1"
-    },
-    "title": {
-      "model": "gpt-4.1"
-    }
   }
 }

internal/app/app.go 🔗

@@ -39,7 +39,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
 	q := db.New(conn)
 	sessions := session.NewService(q)
 	messages := message.NewService(q)
-	files := history.NewService(q)
+	files := history.NewService(q, conn)
 
 	app := &App{
 		Sessions:    sessions,

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

@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS files (
     version TEXT NOT NULL,
     created_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
     updated_at INTEGER NOT NULL,  -- Unix timestamp in milliseconds
-    FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
+    FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
+    UNIQUE(path, session_id, version)
 );
 
 CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);

internal/history/file.go 🔗

@@ -2,9 +2,11 @@ package history
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/google/uuid"
 	"github.com/kujtimiihoxha/opencode/internal/db"
@@ -40,10 +42,11 @@ type Service interface {
 
 type service struct {
 	*pubsub.Broker[File]
-	q db.Querier
+	db *sql.DB
+	q  *db.Queries
 }
 
-func NewService(q db.Querier) Service {
+func NewService(q *db.Queries, db *sql.DB) Service {
 	return &service{
 		Broker: pubsub.NewBroker[File](),
 		q:      q,
@@ -91,19 +94,64 @@ func (s *service) CreateVersion(ctx context.Context, sessionID, path, content st
 }
 
 func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
-	dbFile, err := s.q.CreateFile(ctx, db.CreateFileParams{
-		ID:        uuid.New().String(),
-		SessionID: sessionID,
-		Path:      path,
-		Content:   content,
-		Version:   version,
-	})
-	if err != nil {
-		return File{}, err
+	// Maximum number of retries for transaction conflicts
+	const maxRetries = 3
+	var file File
+	var err error
+
+	// Retry loop for transaction conflicts
+	for attempt := 0; attempt < maxRetries; attempt++ {
+		// Start a transaction
+		tx, err := s.db.BeginTx(ctx, nil)
+		if err != nil {
+			return File{}, fmt.Errorf("failed to begin transaction: %w", err)
+		}
+
+		// Create a new queries instance with the transaction
+		qtx := s.q.WithTx(tx)
+
+		// Try to create the file within the transaction
+		dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
+			ID:        uuid.New().String(),
+			SessionID: sessionID,
+			Path:      path,
+			Content:   content,
+			Version:   version,
+		})
+		if err != nil {
+			// Rollback the transaction
+			tx.Rollback()
+
+			// Check if this is a uniqueness constraint violation
+			if strings.Contains(err.Error(), "UNIQUE constraint failed") {
+				if attempt < maxRetries-1 {
+					// If we have retries left, generate a new version and try again
+					if strings.HasPrefix(version, "v") {
+						versionNum, parseErr := strconv.Atoi(version[1:])
+						if parseErr == nil {
+							version = fmt.Sprintf("v%d", versionNum+1)
+							continue
+						}
+					}
+					// If we can't parse the version, use a timestamp-based version
+					version = fmt.Sprintf("v%d", time.Now().Unix())
+					continue
+				}
+			}
+			return File{}, err
+		}
+
+		// Commit the transaction
+		if err = tx.Commit(); err != nil {
+			return File{}, fmt.Errorf("failed to commit transaction: %w", err)
+		}
+
+		file = s.fromDBItem(dbFile)
+		s.Publish(pubsub.CreatedEvent, file)
+		return file, nil
 	}
-	file := s.fromDBItem(dbFile)
-	s.Publish(pubsub.CreatedEvent, file)
-	return file, nil
+
+	return file, err
 }
 
 func (s *service) Get(ctx context.Context, id string) (File, error) {

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

@@ -118,12 +118,14 @@ func (m *editorCmp) GetSize() (int, int) {
 }
 
 func (m *editorCmp) BindingKeys() []key.Binding {
-	bindings := layout.KeyMapToSlice(m.textarea.KeyMap)
+	bindings := []key.Binding{}
 	if m.textarea.Focused() {
 		bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
 	} else {
 		bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
 	}
+
+	bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
 	return bindings
 }
 

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

@@ -127,7 +127,7 @@ func (m *sidebarCmp) modifiedFiles() string {
 	// If no modified files, show a placeholder message
 	if m.modFiles == nil || len(m.modFiles) == 0 {
 		message := "No modified files"
-		remainingWidth := m.width - lipgloss.Width(modifiedFiles)
+		remainingWidth := m.width - lipgloss.Width(message)
 		if remainingWidth > 0 {
 			message += strings.Repeat(" ", remainingWidth)
 		}
@@ -223,6 +223,9 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
 		if initialVersion.ID == "" {
 			continue
 		}
+		if initialVersion.Content == file.Content {
+			continue
+		}
 
 		// Calculate diff between initial and latest version
 		_, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)

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

@@ -11,6 +11,9 @@ import (
 	"github.com/kujtimiihoxha/opencode/internal/llm/models"
 	"github.com/kujtimiihoxha/opencode/internal/lsp"
 	"github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
+	"github.com/kujtimiihoxha/opencode/internal/pubsub"
+	"github.com/kujtimiihoxha/opencode/internal/session"
+	"github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
 	"github.com/kujtimiihoxha/opencode/internal/tui/styles"
 	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 )
@@ -20,6 +23,7 @@ type statusCmp struct {
 	width      int
 	messageTTL time.Duration
 	lspClients map[string]*lsp.Client
+	session    session.Session
 }
 
 // clearMessageCmd is a command that clears status messages after a timeout
@@ -38,6 +42,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		return m, nil
+	case chat.SessionSelectedMsg:
+		m.session = msg
+	case chat.SessionClearedMsg:
+		m.session = session.Session{}
+	case pubsub.Event[session.Session]:
+		if msg.Type == pubsub.UpdatedEvent {
+			if m.session.ID == msg.Payload.ID {
+				m.session = msg.Payload
+			}
+		}
 	case util.InfoMsg:
 		m.info = msg
 		ttl := msg.TTL
@@ -53,8 +67,43 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
 
+func formatTokensAndCost(tokens int64, cost float64) string {
+	// Format tokens in human-readable format (e.g., 110K, 1.2M)
+	var formattedTokens string
+	switch {
+	case tokens >= 1_000_000:
+		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
+	case tokens >= 1_000:
+		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
+	default:
+		formattedTokens = fmt.Sprintf("%d", tokens)
+	}
+
+	// Remove .0 suffix if present
+	if strings.HasSuffix(formattedTokens, ".0K") {
+		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
+	}
+	if strings.HasSuffix(formattedTokens, ".0M") {
+		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
+	}
+
+	// Format cost with $ symbol and 2 decimal places
+	formattedCost := fmt.Sprintf("$%.2f", cost)
+
+	return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
+}
+
 func (m statusCmp) View() string {
 	status := helpWidget
+	if m.session.ID != "" {
+		tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
+		tokensStyle := styles.Padded.
+			Background(styles.Forground).
+			Foreground(styles.BackgroundDim).
+			Render(tokens)
+		status += tokensStyle
+	}
+
 	diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
 	if m.info.Msg != "" {
 		infoStyle := styles.Padded.
@@ -82,6 +131,7 @@ func (m statusCmp) View() string {
 			Width(m.availableFooterMsgWidth(diagnostics)).
 			Render("")
 	}
+
 	status += diagnostics
 	status += m.model()
 	return status
@@ -136,7 +186,11 @@ func (m *statusCmp) projectDiagnostics() string {
 }
 
 func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
-	return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics))
+	tokens := ""
+	if m.session.ID != "" {
+		tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
+	}
+	return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens))
 }
 
 func (m statusCmp) model() string {

internal/tui/components/dialog/help.go 🔗

@@ -26,7 +26,7 @@ func (h *helpCmp) SetBindings(k []key.Binding) {
 func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		h.width = 80
+		h.width = 90
 		h.height = msg.Height
 	}
 	return h, nil
@@ -62,7 +62,7 @@ func (h *helpCmp) render() string {
 	var (
 		pairs []string
 		width int
-		rows  = 12 - 2
+		rows  = 14 - 2
 	)
 	for i := 0; i < len(bindings); i += rows {
 		var (

internal/tui/page/chat.go 🔗

@@ -15,9 +15,12 @@ import (
 var ChatPage PageID = "chat"
 
 type chatPage struct {
-	app     *app.App
-	layout  layout.SplitPaneLayout
-	session session.Session
+	app         *app.App
+	editor      layout.Container
+	messages    layout.Container
+	layout      layout.SplitPaneLayout
+	session     session.Session
+	editingMode bool
 }
 
 type ChatKeyMap struct {
@@ -59,6 +62,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			return p, cmd
 		}
+	case chat.EditorFocusMsg:
+		p.editingMode = bool(msg)
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, keyMap.NewSession):
@@ -133,7 +138,11 @@ func (p *chatPage) View() string {
 
 func (p *chatPage) BindingKeys() []key.Binding {
 	bindings := layout.KeyMapToSlice(keyMap)
-	bindings = append(bindings, p.layout.BindingKeys()...)
+	if p.editingMode {
+		bindings = append(bindings, p.editor.BindingKeys()...)
+	} else {
+		bindings = append(bindings, p.messages.BindingKeys()...)
+	}
 	return bindings
 }
 
@@ -148,7 +157,10 @@ func NewChatPage(app *app.App) tea.Model {
 		layout.WithBorder(true, false, false, false),
 	)
 	return &chatPage{
-		app: app,
+		app:         app,
+		editor:      editorContainer,
+		messages:    messagesContainer,
+		editingMode: true,
 		layout: layout.NewSplitPane(
 			layout.WithLeftPanel(messagesContainer),
 			layout.WithBottomPanel(editorContainer),

internal/tui/tui.go 🔗

@@ -215,6 +215,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, tea.Batch(cmds...)
 		}
 	}
+
+	a.status, _ = a.status.Update(msg)
 	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 	cmds = append(cmds, cmd)
 	return a, tea.Batch(cmds...)