on main help sign in
small improvements
c24e3c1Kujtim Hoxha
.opencode.json−11
@@ -3,16 +3,5 @@
3 "gopls": {
3 "gopls": {
4 "command": "gopls"
4 "command": "gopls"
5 }
5 }
6− },
7− "agents": {
8− "coder": {
9− "model": "gpt-4.1"
10− },
11− "task": {
12− "model": "gpt-4.1"
13− },
14− "title": {
15− "model": "gpt-4.1"
16− }
17 }
6 }
18}
7}
internal/app/app.go+1−1
@@ -39,7 +39,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
39 q := db.New(conn)
39 q := db.New(conn)
40 sessions := session.NewService(q)
40 sessions := session.NewService(q)
41 messages := message.NewService(q)
41 messages := message.NewService(q)
42− files := history.NewService(q)
42+ files := history.NewService(q, conn)
43
43
44 app := &App{
44 app := &App{
45 Sessions: sessions,
45 Sessions: sessions,
internal/db/migrations/000001_initial.up.sql+2−1
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS files (
27 version TEXT NOT NULL,
27 version TEXT NOT NULL,
28 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
28 created_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
29 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
29 updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
30− FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
30+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
31+ UNIQUE(path, session_id, version)
31);
32);
32
33
33CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
34CREATE INDEX IF NOT EXISTS idx_files_session_id ON files (session_id);
internal/history/file.go+62−14
@@ -2,9 +2,11 @@ package history
2
2
3import (
3import (
4 "context"
4 "context"
5+ "database/sql"
5 "fmt"
6 "fmt"
6 "strconv"
7 "strconv"
7 "strings"
8 "strings"
9+ "time"
8
10
9 "github.com/google/uuid"
11 "github.com/google/uuid"
10 "github.com/kujtimiihoxha/opencode/internal/db"
12 "github.com/kujtimiihoxha/opencode/internal/db"
@@ -40,10 +42,11 @@ type Service interface {
40
42
41type service struct {
43type service struct {
42 *pubsub.Broker[File]
44 *pubsub.Broker[File]
43− q db.Querier
45+ db *sql.DB
46+ q *db.Queries
44}
47}
45
48
46−func NewService(q db.Querier) Service {
49+func NewService(q *db.Queries, db *sql.DB) Service {
47 return &service{
50 return &service{
48 Broker: pubsub.NewBroker[File](),
51 Broker: pubsub.NewBroker[File](),
49 q: q,
52 q: q,
@@ -91,19 +94,64 @@ func (s *service) CreateVersion(ctx context.Context, sessionID, path, content st
91}
94}
92
95
93func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
96func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
97+ // Maximum number of retries for transaction conflicts
98+ const maxRetries = 3
99+ var file File
100+ var err error
101+
102+ // Retry loop for transaction conflicts
103+ for attempt := 0; attempt < maxRetries; attempt++ {
104+ // Start a transaction
105+ tx, err := s.db.BeginTx(ctx, nil)
106+ if err != nil {
107+ return File{}, fmt.Errorf("failed to begin transaction: %w", err)
108+ }
109+
110+ // Create a new queries instance with the transaction
111+ qtx := s.q.WithTx(tx)
112+
113+ // Try to create the file within the transaction
94− dbFile, err := s.q.CreateFile(ctx, db.CreateFileParams{
114+ dbFile, err := qtx.CreateFile(ctx, db.CreateFileParams{
95− ID: uuid.New().String(),
115+ ID: uuid.New().String(),
96− SessionID: sessionID,
116+ SessionID: sessionID,
97− Path: path,
117+ Path: path,
98− Content: content,
118+ Content: content,
99− Version: version,
119+ Version: version,
100− })
120+ })
101− if err != nil {
121+ if err != nil {
122+ // Rollback the transaction
123+ tx.Rollback()
124+
125+ // Check if this is a uniqueness constraint violation
126+ if strings.Contains(err.Error(), "UNIQUE constraint failed") {
127+ if attempt < maxRetries-1 {
128+ // If we have retries left, generate a new version and try again
129+ if strings.HasPrefix(version, "v") {
130+ versionNum, parseErr := strconv.Atoi(version[1:])
131+ if parseErr == nil {
132+ version = fmt.Sprintf("v%d", versionNum+1)
133+ continue
134+ }
135+ }
136+ // If we can't parse the version, use a timestamp-based version
137+ version = fmt.Sprintf("v%d", time.Now().Unix())
138+ continue
139+ }
140+ }
141+ return File{}, err
142+ }
143+
144+ // Commit the transaction
145+ if err = tx.Commit(); err != nil {
102− return File{}, err
146+ return File{}, fmt.Errorf("failed to commit transaction: %w", err)
147+ }
148+
149+ file = s.fromDBItem(dbFile)
150+ s.Publish(pubsub.CreatedEvent, file)
151+ return file, nil
103 }
152 }
104− file := s.fromDBItem(dbFile)
153+
105− s.Publish(pubsub.CreatedEvent, file)
106− return file, nil
154+ return file, err
107}
155}
108
156
109func (s *service) Get(ctx context.Context, id string) (File, error) {
157func (s *service) Get(ctx context.Context, id string) (File, error) {
internal/tui/components/chat/editor.go+3−1
@@ -118,12 +118,14 @@ func (m *editorCmp) GetSize() (int, int) {
118}
118}
119
119
120func (m *editorCmp) BindingKeys() []key.Binding {
120func (m *editorCmp) BindingKeys() []key.Binding {
121− bindings := layout.KeyMapToSlice(m.textarea.KeyMap)
121+ bindings := []key.Binding{}
122 if m.textarea.Focused() {
122 if m.textarea.Focused() {
123 bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
123 bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
124 } else {
124 } else {
125 bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
125 bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
126 }
126 }
127+
128+ bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
127 return bindings
129 return bindings
128}
130}
129
131
internal/tui/components/chat/sidebar.go+4−1
@@ -127,7 +127,7 @@ func (m *sidebarCmp) modifiedFiles() string {
127 // If no modified files, show a placeholder message
127 // If no modified files, show a placeholder message
128 if m.modFiles == nil || len(m.modFiles) == 0 {
128 if m.modFiles == nil || len(m.modFiles) == 0 {
129 message := "No modified files"
129 message := "No modified files"
130− remainingWidth := m.width - lipgloss.Width(modifiedFiles)
130+ remainingWidth := m.width - lipgloss.Width(message)
131 if remainingWidth > 0 {
131 if remainingWidth > 0 {
132 message += strings.Repeat(" ", remainingWidth)
132 message += strings.Repeat(" ", remainingWidth)
133 }
133 }
@@ -223,6 +223,9 @@ func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
223 if initialVersion.ID == "" {
223 if initialVersion.ID == "" {
224 continue
224 continue
225 }
225 }
226+ if initialVersion.Content == file.Content {
227+ continue
228+ }
226
229
227 // Calculate diff between initial and latest version
230 // Calculate diff between initial and latest version
228 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
231 _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
internal/tui/components/core/status.go+55−1
@@ -11,6 +11,9 @@ import (
11 "github.com/kujtimiihoxha/opencode/internal/llm/models"
11 "github.com/kujtimiihoxha/opencode/internal/llm/models"
12 "github.com/kujtimiihoxha/opencode/internal/lsp"
12 "github.com/kujtimiihoxha/opencode/internal/lsp"
13 "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
13 "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
14+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
15+ "github.com/kujtimiihoxha/opencode/internal/session"
16+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
14 "github.com/kujtimiihoxha/opencode/internal/tui/styles"
17 "github.com/kujtimiihoxha/opencode/internal/tui/styles"
15 "github.com/kujtimiihoxha/opencode/internal/tui/util"
18 "github.com/kujtimiihoxha/opencode/internal/tui/util"
16)
19)
@@ -20,6 +23,7 @@ type statusCmp struct {
20 width int
23 width int
21 messageTTL time.Duration
24 messageTTL time.Duration
22 lspClients map[string]*lsp.Client
25 lspClients map[string]*lsp.Client
26+ session session.Session
23}
27}
24
28
25// clearMessageCmd is a command that clears status messages after a timeout
29// 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) {
38 case tea.WindowSizeMsg:
42 case tea.WindowSizeMsg:
39 m.width = msg.Width
43 m.width = msg.Width
40 return m, nil
44 return m, nil
45+ case chat.SessionSelectedMsg:
46+ m.session = msg
47+ case chat.SessionClearedMsg:
48+ m.session = session.Session{}
49+ case pubsub.Event[session.Session]:
50+ if msg.Type == pubsub.UpdatedEvent {
51+ if m.session.ID == msg.Payload.ID {
52+ m.session = msg.Payload
53+ }
54+ }
41 case util.InfoMsg:
55 case util.InfoMsg:
42 m.info = msg
56 m.info = msg
43 ttl := msg.TTL
57 ttl := msg.TTL
@@ -53,8 +67,43 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
53
67
54var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
68var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
55
69
70+func formatTokensAndCost(tokens int64, cost float64) string {
71+ // Format tokens in human-readable format (e.g., 110K, 1.2M)
72+ var formattedTokens string
73+ switch {
74+ case tokens >= 1_000_000:
75+ formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
76+ case tokens >= 1_000:
77+ formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
78+ default:
79+ formattedTokens = fmt.Sprintf("%d", tokens)
80+ }
81+
82+ // Remove .0 suffix if present
83+ if strings.HasSuffix(formattedTokens, ".0K") {
84+ formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
85+ }
86+ if strings.HasSuffix(formattedTokens, ".0M") {
87+ formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
88+ }
89+
90+ // Format cost with $ symbol and 2 decimal places
91+ formattedCost := fmt.Sprintf("$%.2f", cost)
92+
93+ return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
94+}
95+
56func (m statusCmp) View() string {
96func (m statusCmp) View() string {
57 status := helpWidget
97 status := helpWidget
98+ if m.session.ID != "" {
99+ tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
100+ tokensStyle := styles.Padded.
101+ Background(styles.Forground).
102+ Foreground(styles.BackgroundDim).
103+ Render(tokens)
104+ status += tokensStyle
105+ }
106+
58 diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
107 diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
59 if m.info.Msg != "" {
108 if m.info.Msg != "" {
60 infoStyle := styles.Padded.
109 infoStyle := styles.Padded.
@@ -82,6 +131,7 @@ func (m statusCmp) View() string {
82 Width(m.availableFooterMsgWidth(diagnostics)).
131 Width(m.availableFooterMsgWidth(diagnostics)).
83 Render("")
132 Render("")
84 }
133 }
134+
85 status += diagnostics
135 status += diagnostics
86 status += m.model()
136 status += m.model()
87 return status
137 return status
@@ -136,7 +186,11 @@ func (m *statusCmp) projectDiagnostics() string {
136}
186}
137
187
138func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
188func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
189+ tokens := ""
190+ if m.session.ID != "" {
191+ tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
192+ }
139− return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics))
193+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens))
140}
194}
141
195
142func (m statusCmp) model() string {
196func (m statusCmp) model() string {
internal/tui/components/dialog/help.go+2−2
@@ -26,7 +26,7 @@ func (h *helpCmp) SetBindings(k []key.Binding) {
26func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
27 switch msg := msg.(type) {
27 switch msg := msg.(type) {
28 case tea.WindowSizeMsg:
28 case tea.WindowSizeMsg:
29− h.width = 80
29+ h.width = 90
30 h.height = msg.Height
30 h.height = msg.Height
31 }
31 }
32 return h, nil
32 return h, nil
@@ -62,7 +62,7 @@ func (h *helpCmp) render() string {
62 var (
62 var (
63 pairs []string
63 pairs []string
64 width int
64 width int
65− rows = 12 - 2
65+ rows = 14 - 2
66 )
66 )
67 for i := 0; i < len(bindings); i += rows {
67 for i := 0; i < len(bindings); i += rows {
68 var (
68 var (
internal/tui/layout/container.go+1
@@ -10,6 +10,7 @@ import (
10type Container interface {
10type Container interface {
11 tea.Model
11 tea.Model
12 Sizeable
12 Sizeable
13+ Bindings
13}
14}
14type container struct {
15type container struct {
15 width int
16 width int
internal/tui/page/chat.go+17−5
@@ -15,9 +15,12 @@ import (
15var ChatPage PageID = "chat"
15var ChatPage PageID = "chat"
16
16
17type chatPage struct {
17type chatPage struct {
18− app *app.App
18+ app *app.App
19+ editor layout.Container
20+ messages layout.Container
19− layout layout.SplitPaneLayout
21+ layout layout.SplitPaneLayout
20− session session.Session
22+ session session.Session
23+ editingMode bool
21}
24}
22
25
23type ChatKeyMap struct {
26type ChatKeyMap struct {
@@ -59,6 +62,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 if cmd != nil {
62 if cmd != nil {
60 return p, cmd
63 return p, cmd
61 }
64 }
65+ case chat.EditorFocusMsg:
66+ p.editingMode = bool(msg)
62 case tea.KeyMsg:
67 case tea.KeyMsg:
63 switch {
68 switch {
64 case key.Matches(msg, keyMap.NewSession):
69 case key.Matches(msg, keyMap.NewSession):
@@ -133,7 +138,11 @@ func (p *chatPage) View() string {
133
138
134func (p *chatPage) BindingKeys() []key.Binding {
139func (p *chatPage) BindingKeys() []key.Binding {
135 bindings := layout.KeyMapToSlice(keyMap)
140 bindings := layout.KeyMapToSlice(keyMap)
141+ if p.editingMode {
136− bindings = append(bindings, p.layout.BindingKeys()...)
142+ bindings = append(bindings, p.editor.BindingKeys()...)
143+ } else {
144+ bindings = append(bindings, p.messages.BindingKeys()...)
145+ }
137 return bindings
146 return bindings
138}
147}
139
148
@@ -148,7 +157,10 @@ func NewChatPage(app *app.App) tea.Model {
148 layout.WithBorder(true, false, false, false),
157 layout.WithBorder(true, false, false, false),
149 )
158 )
150 return &chatPage{
159 return &chatPage{
151− app: app,
160+ app: app,
161+ editor: editorContainer,
162+ messages: messagesContainer,
163+ editingMode: true,
152 layout: layout.NewSplitPane(
164 layout: layout.NewSplitPane(
153 layout.WithLeftPanel(messagesContainer),
165 layout.WithLeftPanel(messagesContainer),
154 layout.WithBottomPanel(editorContainer),
166 layout.WithBottomPanel(editorContainer),
internal/tui/tui.go+2
@@ -215,6 +215,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
215 return a, tea.Batch(cmds...)
215 return a, tea.Batch(cmds...)
216 }
216 }
217 }
217 }
218+
219+ a.status, _ = a.status.Update(msg)
218 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
220 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
219 cmds = append(cmds, cmd)
221 cmds = append(cmds, cmd)
220 return a, tea.Batch(cmds...)
222 return a, tea.Batch(cmds...)