Detailed changes
@@ -3,16 +3,5 @@
"gopls": {
"command": "gopls"
}
- },
- "agents": {
- "coder": {
- "model": "gpt-4.1"
- },
- "task": {
- "model": "gpt-4.1"
- },
- "title": {
- "model": "gpt-4.1"
- }
}
}
@@ -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,
@@ -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);
@@ -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) {
@@ -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
}
@@ -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)
@@ -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 {
@@ -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 (
@@ -10,6 +10,7 @@ import (
type Container interface {
tea.Model
Sizeable
+ Bindings
}
type container struct {
width int
@@ -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),
@@ -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...)