diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000..42c02f15d2d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,24 @@ +# OpenCode Development Context + +## Build Commands +- Build: `go build` +- Run: `go run main.go` +- Test: `go test ./...` +- Test single package: `go test ./internal/package/...` +- Test single test: `go test ./internal/package -run TestName` +- Verbose test: `go test -v ./...` +- Coverage: `go test -cover ./...` +- Lint: `go vet ./...` +- Format: `go fmt ./...` +- Build snapshot: `./scripts/snapshot` + +## Code Style +- Use Go 1.24+ features +- Follow standard Go formatting (gofmt) +- Use table-driven tests with t.Parallel() when possible +- Error handling: check errors immediately, return early +- Naming: CamelCase for exported, camelCase for unexported +- Imports: standard library first, then external, then internal +- Use context.Context for cancellation and timeouts +- Prefer interfaces for dependencies to enable testing +- Use testify for assertions in tests \ No newline at end of file diff --git a/go.mod b/go.mod index 52c5e81a1ae..26ca419c54d 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.1 - github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.8.0 github.com/fsnotify/fsnotify v1.8.0 @@ -68,11 +67,9 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -91,7 +88,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index c41acf62901..0f8999e1f54 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= -github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= -github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= @@ -92,8 +90,6 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -169,8 +165,6 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/config/config.go b/internal/config/config.go index c46f80f95b2..f3682763b20 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -102,6 +102,8 @@ var defaultContextPaths = []string{ ".cursor/rules/", "CLAUDE.md", "CLAUDE.local.md", + "CONTEXT.md", + "CONTEXT.local.md", "opencode.md", "opencode.local.md", "OpenCode.md", diff --git a/internal/db/db.go b/internal/db/db.go index 16e66380405..e71b8622716 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package db @@ -72,6 +72,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listMessagesBySessionStmt, err = db.PrepareContext(ctx, listMessagesBySession); err != nil { return nil, fmt.Errorf("error preparing query ListMessagesBySession: %w", err) } + if q.listMessagesBySessionAfterStmt, err = db.PrepareContext(ctx, listMessagesBySessionAfter); err != nil { + return nil, fmt.Errorf("error preparing query ListMessagesBySessionAfter: %w", err) + } if q.listNewFilesStmt, err = db.PrepareContext(ctx, listNewFiles); err != nil { return nil, fmt.Errorf("error preparing query ListNewFiles: %w", err) } @@ -172,6 +175,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listMessagesBySessionStmt: %w", cerr) } } + if q.listMessagesBySessionAfterStmt != nil { + if cerr := q.listMessagesBySessionAfterStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listMessagesBySessionAfterStmt: %w", cerr) + } + } if q.listNewFilesStmt != nil { if cerr := q.listNewFilesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listNewFilesStmt: %w", cerr) @@ -234,55 +242,57 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createFileStmt *sql.Stmt - createMessageStmt *sql.Stmt - createSessionStmt *sql.Stmt - deleteFileStmt *sql.Stmt - deleteMessageStmt *sql.Stmt - deleteSessionStmt *sql.Stmt - deleteSessionFilesStmt *sql.Stmt - deleteSessionMessagesStmt *sql.Stmt - getFileStmt *sql.Stmt - getFileByPathAndSessionStmt *sql.Stmt - getMessageStmt *sql.Stmt - getSessionByIDStmt *sql.Stmt - listFilesByPathStmt *sql.Stmt - listFilesBySessionStmt *sql.Stmt - listLatestSessionFilesStmt *sql.Stmt - listMessagesBySessionStmt *sql.Stmt - listNewFilesStmt *sql.Stmt - listSessionsStmt *sql.Stmt - updateFileStmt *sql.Stmt - updateMessageStmt *sql.Stmt - updateSessionStmt *sql.Stmt + db DBTX + tx *sql.Tx + createFileStmt *sql.Stmt + createMessageStmt *sql.Stmt + createSessionStmt *sql.Stmt + deleteFileStmt *sql.Stmt + deleteMessageStmt *sql.Stmt + deleteSessionStmt *sql.Stmt + deleteSessionFilesStmt *sql.Stmt + deleteSessionMessagesStmt *sql.Stmt + getFileStmt *sql.Stmt + getFileByPathAndSessionStmt *sql.Stmt + getMessageStmt *sql.Stmt + getSessionByIDStmt *sql.Stmt + listFilesByPathStmt *sql.Stmt + listFilesBySessionStmt *sql.Stmt + listLatestSessionFilesStmt *sql.Stmt + listMessagesBySessionStmt *sql.Stmt + listMessagesBySessionAfterStmt *sql.Stmt + listNewFilesStmt *sql.Stmt + listSessionsStmt *sql.Stmt + updateFileStmt *sql.Stmt + updateMessageStmt *sql.Stmt + updateSessionStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createFileStmt: q.createFileStmt, - createMessageStmt: q.createMessageStmt, - createSessionStmt: q.createSessionStmt, - deleteFileStmt: q.deleteFileStmt, - deleteMessageStmt: q.deleteMessageStmt, - deleteSessionStmt: q.deleteSessionStmt, - deleteSessionFilesStmt: q.deleteSessionFilesStmt, - deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, - getFileStmt: q.getFileStmt, - getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, - getMessageStmt: q.getMessageStmt, - getSessionByIDStmt: q.getSessionByIDStmt, - listFilesByPathStmt: q.listFilesByPathStmt, - listFilesBySessionStmt: q.listFilesBySessionStmt, - listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, - listMessagesBySessionStmt: q.listMessagesBySessionStmt, - listNewFilesStmt: q.listNewFilesStmt, - listSessionsStmt: q.listSessionsStmt, - updateFileStmt: q.updateFileStmt, - updateMessageStmt: q.updateMessageStmt, - updateSessionStmt: q.updateSessionStmt, + db: tx, + tx: tx, + createFileStmt: q.createFileStmt, + createMessageStmt: q.createMessageStmt, + createSessionStmt: q.createSessionStmt, + deleteFileStmt: q.deleteFileStmt, + deleteMessageStmt: q.deleteMessageStmt, + deleteSessionStmt: q.deleteSessionStmt, + deleteSessionFilesStmt: q.deleteSessionFilesStmt, + deleteSessionMessagesStmt: q.deleteSessionMessagesStmt, + getFileStmt: q.getFileStmt, + getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getMessageStmt: q.getMessageStmt, + getSessionByIDStmt: q.getSessionByIDStmt, + listFilesByPathStmt: q.listFilesByPathStmt, + listFilesBySessionStmt: q.listFilesBySessionStmt, + listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, + listMessagesBySessionStmt: q.listMessagesBySessionStmt, + listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt, + listNewFilesStmt: q.listNewFilesStmt, + listSessionsStmt: q.listSessionsStmt, + updateFileStmt: q.updateFileStmt, + updateMessageStmt: q.updateMessageStmt, + updateSessionStmt: q.updateSessionStmt, } } diff --git a/internal/db/files.sql.go b/internal/db/files.sql.go index 39def271f10..28abaa55d73 100644 --- a/internal/db/files.sql.go +++ b/internal/db/files.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: files.sql package db diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go index 0555b4330d7..15ef7695b2c 100644 --- a/internal/db/messages.sql.go +++ b/internal/db/messages.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: messages.sql package db @@ -136,6 +136,50 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ( return items, nil } +const listMessagesBySessionAfter = `-- name: ListMessagesBySessionAfter :many +SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at +FROM messages +WHERE session_id = ? AND created_at > ? +ORDER BY created_at ASC +` + +type ListMessagesBySessionAfterParams struct { + SessionID string `json:"session_id"` + CreatedAt int64 `json:"created_at"` +} + +func (q *Queries) ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) { + rows, err := q.query(ctx, q.listMessagesBySessionAfterStmt, listMessagesBySessionAfter, arg.SessionID, arg.CreatedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Parts, + &i.Model, + &i.CreatedAt, + &i.UpdatedAt, + &i.FinishedAt, + ); 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 updateMessage = `-- name: UpdateMessage :exec UPDATE messages SET diff --git a/internal/db/migrations/20250502063010_add_summary_to_sessions.sql b/internal/db/migrations/20250502063010_add_summary_to_sessions.sql new file mode 100644 index 00000000000..9cecfcedb3d --- /dev/null +++ b/internal/db/migrations/20250502063010_add_summary_to_sessions.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN summary TEXT; +ALTER TABLE sessions ADD COLUMN summarized_at INTEGER; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN summarized_at; +ALTER TABLE sessions DROP COLUMN summary; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/db/models.go b/internal/db/models.go index f00cb6ad17e..47028c19a3e 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package db @@ -39,4 +39,6 @@ type Session struct { Cost float64 `json:"cost"` UpdatedAt int64 `json:"updated_at"` CreatedAt int64 `json:"created_at"` + Summary sql.NullString `json:"summary"` + SummarizedAt sql.NullInt64 `json:"summarized_at"` } diff --git a/internal/db/querier.go b/internal/db/querier.go index 704a97da26c..ee0a2f7bd15 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 package db @@ -25,6 +25,7 @@ type Querier interface { ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) + ListMessagesBySessionAfter(ctx context.Context, arg ListMessagesBySessionAfterParams) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) ListSessions(ctx context.Context) ([]Session, error) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, error) diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go index 18d70c3dbdb..f6150b40fc4 100644 --- a/internal/db/sessions.sql.go +++ b/internal/db/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.29.0 // source: sessions.sql package db @@ -19,6 +19,8 @@ INSERT INTO sessions ( prompt_tokens, completion_tokens, cost, + summary, + summarized_at, updated_at, created_at ) VALUES ( @@ -29,9 +31,11 @@ INSERT INTO sessions ( ?, ?, ?, + ?, + ?, strftime('%s', 'now'), strftime('%s', 'now') -) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at +) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary, summarized_at ` type CreateSessionParams struct { @@ -42,6 +46,8 @@ type CreateSessionParams struct { PromptTokens int64 `json:"prompt_tokens"` CompletionTokens int64 `json:"completion_tokens"` Cost float64 `json:"cost"` + Summary sql.NullString `json:"summary"` + SummarizedAt sql.NullInt64 `json:"summarized_at"` } func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { @@ -53,6 +59,8 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S arg.PromptTokens, arg.CompletionTokens, arg.Cost, + arg.Summary, + arg.SummarizedAt, ) var i Session err := row.Scan( @@ -65,6 +73,8 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S &i.Cost, &i.UpdatedAt, &i.CreatedAt, + &i.Summary, + &i.SummarizedAt, ) return i, err } @@ -80,7 +90,7 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error { } const getSessionByID = `-- name: GetSessionByID :one -SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at +SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary, summarized_at FROM sessions WHERE id = ? LIMIT 1 ` @@ -98,12 +108,14 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error &i.Cost, &i.UpdatedAt, &i.CreatedAt, + &i.Summary, + &i.SummarizedAt, ) return i, err } const listSessions = `-- name: ListSessions :many -SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at +SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary, summarized_at FROM sessions WHERE parent_session_id is NULL ORDER BY created_at DESC @@ -128,6 +140,8 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) { &i.Cost, &i.UpdatedAt, &i.CreatedAt, + &i.Summary, + &i.SummarizedAt, ); err != nil { return nil, err } @@ -148,17 +162,21 @@ SET title = ?, prompt_tokens = ?, completion_tokens = ?, - cost = ? + cost = ?, + summary = ?, + summarized_at = ? WHERE id = ? -RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at +RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary, summarized_at ` type UpdateSessionParams struct { - Title string `json:"title"` - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` - Cost float64 `json:"cost"` - ID string `json:"id"` + Title string `json:"title"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + Cost float64 `json:"cost"` + Summary sql.NullString `json:"summary"` + SummarizedAt sql.NullInt64 `json:"summarized_at"` + ID string `json:"id"` } func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) { @@ -167,6 +185,8 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S arg.PromptTokens, arg.CompletionTokens, arg.Cost, + arg.Summary, + arg.SummarizedAt, arg.ID, ) var i Session @@ -180,6 +200,8 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S &i.Cost, &i.UpdatedAt, &i.CreatedAt, + &i.Summary, + &i.SummarizedAt, ) return i, err } diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql index a59cebe7d00..475b23a86c0 100644 --- a/internal/db/sql/messages.sql +++ b/internal/db/sql/messages.sql @@ -9,6 +9,12 @@ FROM messages WHERE session_id = ? ORDER BY created_at ASC; +-- name: ListMessagesBySessionAfter :many +SELECT * +FROM messages +WHERE session_id = ? AND created_at > ? +ORDER BY created_at ASC; + -- name: CreateMessage :one INSERT INTO messages ( id, diff --git a/internal/db/sql/sessions.sql b/internal/db/sql/sessions.sql index f065b5f5614..81abebd3c15 100644 --- a/internal/db/sql/sessions.sql +++ b/internal/db/sql/sessions.sql @@ -7,6 +7,8 @@ INSERT INTO sessions ( prompt_tokens, completion_tokens, cost, + summary, + summarized_at, updated_at, created_at ) VALUES ( @@ -17,6 +19,8 @@ INSERT INTO sessions ( ?, ?, ?, + ?, + ?, strftime('%s', 'now'), strftime('%s', 'now') ) RETURNING *; @@ -38,7 +42,9 @@ SET title = ?, prompt_tokens = ?, completion_tokens = ?, - cost = ? + cost = ?, + summary = ?, + summarized_at = ? WHERE id = ? RETURNING *; diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 80dfeb0fdbf..ba65f594ec6 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "sync" + "time" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/llm/models" @@ -43,6 +44,9 @@ type Service interface { IsSessionBusy(sessionID string) bool IsBusy() bool Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) + CompactSession(ctx context.Context, sessionID string) error + PauseSession(sessionID string) error + ResumeSession(sessionID string) error } type agent struct { @@ -55,6 +59,7 @@ type agent struct { titleProvider provider.Provider activeRequests sync.Map + pauseLock sync.RWMutex // Lock for pausing message processing } func NewAgent( @@ -187,12 +192,30 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string) (<-ch } func (a *agent) processGeneration(ctx context.Context, sessionID, content string) AgentEvent { - // List existing messages; if none, start title generation asynchronously. - msgs, err := a.messages.List(ctx, sessionID) + // Get the current session to check for summary + currentSession, err := a.sessions.Get(ctx, sessionID) if err != nil { - return a.err(fmt.Errorf("failed to list messages: %w", err)) + return a.err(fmt.Errorf("failed to get session: %w", err)) } - if len(msgs) == 0 { + + // Fetch messages based on whether a summary exists + var sessionMessages []message.Message + if currentSession.Summary != "" && currentSession.SummarizedAt > 0 { + // If summary exists, only fetch messages after the summarization timestamp + sessionMessages, err = a.messages.ListAfter(ctx, sessionID, currentSession.SummarizedAt) + if err != nil { + return a.err(fmt.Errorf("failed to list messages after summary: %w", err)) + } + } else { + // If no summary, fetch all messages + sessionMessages, err = a.messages.List(ctx, sessionID) + if err != nil { + return a.err(fmt.Errorf("failed to list messages: %w", err)) + } + } + + // If this is a new session, start title generation asynchronously + if len(sessionMessages) == 0 && currentSession.Summary == "" { go func() { defer logging.RecoverPanic("agent.Run", func() { logging.ErrorPersist("panic while generating title") @@ -209,8 +232,25 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string return a.err(fmt.Errorf("failed to create user message: %w", err)) } - // Append the new user message to the conversation history. - msgHistory := append(msgs, userMsg) + // Prepare the message history for the LLM + var messages []message.Message + if currentSession.Summary != "" && currentSession.SummarizedAt > 0 { + // If summary exists, create a temporary message for the summary + summaryMessage := message.Message{ + Role: message.Assistant, + Parts: []message.ContentPart{ + message.TextContent{Text: currentSession.Summary}, + }, + } + // Start with the summary, then add messages after the summary timestamp + messages = append([]message.Message{summaryMessage}, sessionMessages...) + } else { + // If no summary, just use all messages + messages = sessionMessages + } + + // Append the new user message to the conversation history + messages = append(messages, userMsg) for { // Check for cancellation before each iteration select { @@ -219,7 +259,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string default: // Continue processing } - agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory) + agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, messages) if err != nil { if errors.Is(err, context.Canceled) { agentMessage.AddFinish(message.FinishReasonCanceled) @@ -231,7 +271,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string logging.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults) if (agentMessage.FinishReason() == message.FinishReasonToolUse) && toolResults != nil { // We are not done, we need to respond with the tool response - msgHistory = append(msgHistory, agentMessage, *toolResults) + messages = append(messages, agentMessage, *toolResults) continue } return AgentEvent{ @@ -249,7 +289,76 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string }) } +// estimateTokens provides a rough estimate of token count based on character count +// using a simple heuristic of ~4 characters per token +func estimateTokens(messages []message.Message) int64 { + totalChars := 0 + for _, msg := range messages { + // Get text content from all parts + for _, part := range msg.Parts { + if textContent, ok := part.(message.TextContent); ok { + totalChars += len(textContent.Text) + } else { + // For non-text parts, add a conservative estimate + totalChars += 100 + } + } + // Add chars for role (conservative estimate) + totalChars += 10 + } + // Heuristic: ~4 chars per token + return int64(totalChars / 4) +} + func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) { + // Check if we need to auto-compact based on token count + contextWindow := a.provider.Model().ContextWindow + threshold := int64(float64(contextWindow) * 0.80) + estimatedTokens := estimateTokens(msgHistory) + + // If we're approaching the context window limit, trigger auto-compaction + if estimatedTokens >= threshold { + logging.InfoPersist(fmt.Sprintf("Auto-compaction triggered for session %s. Estimated tokens: %d, Threshold: %d", sessionID, estimatedTokens, threshold)) + + // Perform compaction with pause/resume to ensure safety + if err := a.CompactSession(ctx, sessionID); err != nil { + logging.ErrorPersist(fmt.Sprintf("Auto-compaction failed: %v", err)) + // Continue with the request even if compaction fails + } else { + // Re-fetch session details after compaction + currentSession, err := a.sessions.Get(ctx, sessionID) + if err != nil { + return message.Message{}, nil, fmt.Errorf("failed to get session after compaction: %w", err) + } + + // Re-prepare messages using the new summary + var sessionMessages []message.Message + if currentSession.Summary != "" && currentSession.SummarizedAt > 0 { + // If summary exists, only fetch messages after the summarization timestamp + sessionMessages, err = a.messages.ListAfter(ctx, sessionID, currentSession.SummarizedAt) + if err != nil { + return message.Message{}, nil, fmt.Errorf("failed to list messages after compaction: %w", err) + } + + // Create a new message history with the summary and messages after summarization + summaryMessage := message.Message{ + Role: message.Assistant, + Parts: []message.ContentPart{ + message.TextContent{Text: currentSession.Summary}, + }, + } + + // Replace msgHistory with the new compacted version + msgHistory = append([]message.Message{summaryMessage}, sessionMessages...) + + // Log the new token estimate after compaction + newEstimate := estimateTokens(msgHistory) + logging.InfoPersist(fmt.Sprintf("After compaction: Estimated tokens: %d (reduced by %d)", + newEstimate, estimatedTokens-newEstimate)) + } + } + } + eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools) assistantMsg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{ @@ -374,6 +483,10 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg // Continue processing. } + // Check if session is paused - use RLock to allow concurrent reads but block during pause + a.pauseLock.RLock() + defer a.pauseLock.RUnlock() + switch event.Type { case provider.EventThinkingDelta: assistantMsg.AppendReasoningContent(event.Content) @@ -456,6 +569,145 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode return a.provider.Model(), nil } +// PauseSession pauses message processing for a specific session +// This should be called before performing operations that require exclusive access +func (a *agent) PauseSession(sessionID string) error { + if !a.IsSessionBusy(sessionID) { + return nil // Session is not active, no need to pause + } + + logging.InfoPersist(fmt.Sprintf("Pausing session: %s", sessionID)) + a.pauseLock.Lock() // Acquire write lock to block new operations + return nil +} + +// ResumeSession resumes message processing for a session +// This should be called after completing operations that required exclusive access +func (a *agent) ResumeSession(sessionID string) error { + logging.InfoPersist(fmt.Sprintf("Resuming session: %s", sessionID)) + a.pauseLock.Unlock() // Release write lock to allow operations to continue + return nil +} + +func (a *agent) CompactSession(ctx context.Context, sessionID string) error { + // Check if the session is busy + if a.IsSessionBusy(sessionID) { + // Pause the session before compaction + if err := a.PauseSession(sessionID); err != nil { + return fmt.Errorf("failed to pause session: %w", err) + } + // Make sure to resume the session when we're done + defer a.ResumeSession(sessionID) + logging.InfoPersist(fmt.Sprintf("Session %s paused for compaction", sessionID)) + } + + // Create a cancellable context + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Mark the session as busy during compaction + compactionCancelFunc := func() {} + a.activeRequests.Store(sessionID+"-compact", compactionCancelFunc) + defer a.activeRequests.Delete(sessionID + "-compact") + + // Fetch the session + session, err := a.sessions.Get(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to get session: %w", err) + } + + // Fetch all messages for the session + sessionMessages, err := a.messages.List(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to list messages: %w", err) + } + + var existingSummary string + if session.Summary != "" && session.SummarizedAt > 0 { + // Filter messages that were created after the last summarization + var newMessages []message.Message + for _, msg := range sessionMessages { + if msg.CreatedAt > session.SummarizedAt { + newMessages = append(newMessages, msg) + } + } + sessionMessages = newMessages + existingSummary = session.Summary + } + + // If there are no messages to summarize and no existing summary, return early + if len(sessionMessages) == 0 && existingSummary == "" { + return nil + } + + messages := []message.Message{ + message.Message{ + Role: message.System, + Parts: []message.ContentPart{ + message.TextContent{ + Text: "You are a helpful AI assistant tasked with summarizing conversations.", + }, + }, + }, + } + + // If there's an existing summary, include it + if existingSummary != "" { + messages = append(messages, message.Message{ + Role: message.Assistant, // TODO: should this be system or user instead? + Parts: []message.ContentPart{ + message.TextContent{ + Text: existingSummary, + }, + }, + }) + } + + // Add all messages since the last summarized message + messages = append(messages, sessionMessages...) + + // Add a final user message requesting the summary + messages = append(messages, message.Message{ + Role: message.User, + Parts: []message.ContentPart{ + message.TextContent{ + Text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.", + }, + }, + }) + + // Call provider to get the summary + response, err := a.provider.SendMessages(ctx, messages, a.tools) + if err != nil { + return fmt.Errorf("failed to get summary from the assistant: %w", err) + } + + // Extract the summary text + summaryText := strings.TrimSpace(response.Content) + if summaryText == "" { + return fmt.Errorf("received empty summary from the assistant") + } + + // Update the session with the new summary + currentTime := time.Now().UnixMilli() + session.Summary = summaryText + session.SummarizedAt = currentTime + + // Save the updated session + _, err = a.sessions.Save(ctx, session) + if err != nil { + return fmt.Errorf("failed to save session with summary: %w", err) + } + + // Track token usage + err = a.TrackUsage(ctx, sessionID, a.provider.Model(), response.Usage) + if err != nil { + return fmt.Errorf("failed to track usage: %w", err) + } + + return nil +} + func createAgentProvider(agentName config.AgentName) (provider.Provider, error) { cfg := config.Get() agentConfig, ok := cfg.Agents[agentName] diff --git a/internal/llm/agent/tools.go b/internal/llm/agent/tools.go index e6b0119aef3..43e5978e4cb 100644 --- a/internal/llm/agent/tools.go +++ b/internal/llm/agent/tools.go @@ -20,9 +20,11 @@ func CoderAgentTools( ) []tools.BaseTool { ctx := context.Background() otherTools := GetMcpTools(ctx, permissions) - if len(lspClients) > 0 { - otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients)) - } + + // Always add the Diagnostics tool even if lspClients is empty + // The tool will handle the case when no clients are available + otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients)) + return append( []tools.BaseTool{ tools.NewBashTool(permissions), diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 1e1cbde50bf..3403bec2d8e 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -79,6 +79,9 @@ var SupportedModels = map[ModelID]Model{ CostPer1MInCached: 3.75, CostPer1MOutCached: 0.30, CostPer1MOut: 15.0, + ContextWindow: 200_000, + DefaultMaxTokens: 50_000, + CanReason: true, }, } diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index 7d2c875afab..639679328fb 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -81,7 +81,7 @@ If the current working directory contains a file called OpenCode.md, it will be 2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.) 3. Maintaining useful information about the codebase structure and organization -When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to OpenCode.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to OpenCode.md so you can remember it for next time. +When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CONTEXT.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CONTEXT.md so you can remember it for next time. # Tone and style You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). diff --git a/internal/llm/provider/anthropic.go b/internal/llm/provider/anthropic.go index fc131d348ac..a96fe83ee72 100644 --- a/internal/llm/provider/anthropic.go +++ b/internal/llm/provider/anthropic.go @@ -74,14 +74,19 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic case message.Assistant: blocks := []anthropic.ContentBlockParamUnion{} - if msg.Content().String() != "" { - content := anthropic.NewTextBlock(msg.Content().String()) + + if msg.Content() != nil { + content := msg.Content().String() + if strings.TrimSpace(content) == "" { + content = " " + } + block := anthropic.NewTextBlock(content) if cache && !a.options.disableCache { - content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ + block.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{ Type: "ephemeral", } } - blocks = append(blocks, content) + blocks = append(blocks, block) } for _, toolCall := range msg.ToolCalls() { @@ -196,8 +201,8 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message, preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) cfg := config.Get() if cfg.Debug { - // jsonData, _ := json.Marshal(preparedMessages) - // logging.Debug("Prepared messages", "messages", string(jsonData)) + jsonData, _ := json.Marshal(preparedMessages) + logging.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 for { @@ -243,8 +248,8 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message preparedMessages := a.preparedMessages(a.convertMessages(messages), a.convertTools(tools)) cfg := config.Get() if cfg.Debug { - // jsonData, _ := json.Marshal(preparedMessages) - // logging.Debug("Prepared messages", "messages", string(jsonData)) + jsonData, _ := json.Marshal(preparedMessages) + logging.Debug("Prepared messages", "messages", string(jsonData)) } attempts := 0 eventChan := make(chan ProviderEvent) diff --git a/internal/llm/tools/diagnostics.go b/internal/llm/tools/diagnostics.go index b4c5941c41a..5355c164be9 100644 --- a/internal/llm/tools/diagnostics.go +++ b/internal/llm/tools/diagnostics.go @@ -74,7 +74,8 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, lsps := b.lspClients if len(lsps) == 0 { - return NewTextErrorResponse("no LSP clients available"), nil + // Return a more helpful message when LSP clients aren't ready yet + return NewTextResponse("\n\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n\n"), nil } if params.FilePath != "" { diff --git a/internal/llm/tools/shell/shell.go b/internal/llm/tools/shell/shell.go index 5731faec345..668a69fa158 100644 --- a/internal/llm/tools/shell/shell.go +++ b/internal/llm/tools/shell/shell.go @@ -11,6 +11,8 @@ import ( "sync" "syscall" "time" + + "github.com/opencode-ai/opencode/internal/logging" ) type PersistentShell struct { @@ -99,7 +101,7 @@ func newPersistentShell(cwd string) *PersistentShell { go func() { err := cmd.Wait() if err != nil { - // Log the error if needed + logging.ErrorPersist(fmt.Sprintf("Shell process exited with error: %v", err)) } shell.isAlive = false close(shell.commandQueue) diff --git a/internal/message/content.go b/internal/message/content.go index 1ea2bccaa18..c42154cfd8f 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -48,7 +48,10 @@ type TextContent struct { Text string `json:"text"` } -func (tc TextContent) String() string { +func (tc *TextContent) String() string { + if tc == nil { + return "" + } return tc.Text } @@ -115,13 +118,13 @@ type Message struct { UpdatedAt int64 } -func (m *Message) Content() TextContent { +func (m *Message) Content() *TextContent { for _, part := range m.Parts { if c, ok := part.(TextContent); ok { - return c + return &c } } - return TextContent{} + return nil } func (m *Message) ReasoningContent() ReasoningContent { diff --git a/internal/message/message.go b/internal/message/message.go index b26af92f423..e091cdfa149 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -25,6 +25,7 @@ type Service interface { Update(ctx context.Context, message Message) error Get(ctx context.Context, id string) (Message, error) List(ctx context.Context, sessionID string) ([]Message, error) + ListAfter(ctx context.Context, sessionID string, timestamp int64) ([]Message, error) Delete(ctx context.Context, id string) error DeleteSessionMessages(ctx context.Context, sessionID string) error } @@ -145,6 +146,24 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) return messages, nil } +func (s *service) ListAfter(ctx context.Context, sessionID string, timestamp int64) ([]Message, error) { + dbMessages, err := s.q.ListMessagesBySessionAfter(ctx, db.ListMessagesBySessionAfterParams{ + SessionID: sessionID, + CreatedAt: timestamp, + }) + if err != nil { + return nil, err + } + messages := make([]Message, len(dbMessages)) + for i, dbMessage := range dbMessages { + messages[i], err = s.fromDBItem(dbMessage) + if err != nil { + return nil, err + } + } + return messages, nil +} + func (s *service) fromDBItem(item db.Message) (Message, error) { parts, err := unmarshallParts([]byte(item.Parts)) if err != nil { diff --git a/internal/session/session.go b/internal/session/session.go index 682ea7768d6..be395bb87ac 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -17,6 +17,8 @@ type Session struct { PromptTokens int64 CompletionTokens int64 Cost float64 + Summary string + SummarizedAt int64 CreatedAt int64 UpdatedAt int64 } @@ -100,16 +102,31 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) { } func (s *service) Save(ctx context.Context, session Session) (Session, error) { + summary := sql.NullString{} + if session.Summary != "" { + summary.String = session.Summary + summary.Valid = true + } + + summarizedAt := sql.NullInt64{} + if session.SummarizedAt != 0 { + summarizedAt.Int64 = session.SummarizedAt + summarizedAt.Valid = true + } + dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{ ID: session.ID, Title: session.Title, PromptTokens: session.PromptTokens, CompletionTokens: session.CompletionTokens, Cost: session.Cost, + Summary: summary, + SummarizedAt: summarizedAt, }) if err != nil { return Session{}, err } + session = s.fromDBItem(dbSession) s.Publish(pubsub.UpdatedEvent, session) return session, nil @@ -136,6 +153,8 @@ func (s service) fromDBItem(item db.Session) Session { PromptTokens: item.PromptTokens, CompletionTokens: item.CompletionTokens, Cost: item.Cost, + Summary: item.Summary.String, + SummarizedAt: item.SummarizedAt.Int64, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index d6eaecec9ee..52c9a4f7182 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -23,6 +23,8 @@ type SessionClearedMsg struct{} type EditorFocusMsg bool +type CompactSessionMsg struct{} + func header(width int) string { return lipgloss.JoinVertical( lipgloss.Top, diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 1dfc3ab20f6..2bddb19da42 100644 --- a/internal/tui/components/chat/list.go +++ b/internal/tui/components/chat/list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -450,8 +451,11 @@ func (m *messagesCmp) BindingKeys() []key.Binding { } func NewMessagesCmp(app *app.App) tea.Model { - s := spinner.New() - s.Spinner = spinner.Pulse + customSpinner := spinner.Spinner{ + Frames: []string{" ", "┃"}, + FPS: time.Second / 2, //nolint:gomnd + } + s := spinner.New(spinner.WithSpinner(customSpinner)) vp := viewport.New(0, 0) vp.KeyMap.PageUp = messageKeys.PageUp vp.KeyMap.PageDown = messageKeys.PageDown diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go index 28120a430bd..54b8d4872c0 100644 --- a/internal/tui/components/core/status.go +++ b/internal/tui/components/core/status.go @@ -216,32 +216,36 @@ func (m *statusCmp) projectDiagnostics() string { diagnostics := []string{} + errIcon := styles.CircledDigit(len(errorDiagnostics)) errStr := lipgloss.NewStyle(). Background(t.BackgroundDarker()). Foreground(t.Error()). - Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics))) + Render(errIcon) diagnostics = append(diagnostics, errStr) + warnIcon := styles.CircledDigit(len(warnDiagnostics)) warnStr := lipgloss.NewStyle(). Background(t.BackgroundDarker()). Foreground(t.Warning()). - Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics))) + Render(warnIcon) diagnostics = append(diagnostics, warnStr) + infoIcon := styles.CircledDigit(len(infoDiagnostics)) infoStr := lipgloss.NewStyle(). Background(t.BackgroundDarker()). Foreground(t.Info()). - Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics))) + Render(infoIcon) diagnostics = append(diagnostics, infoStr) + hintIcon := styles.CircledDigit(len(hintDiagnostics)) hintStr := lipgloss.NewStyle(). Background(t.BackgroundDarker()). Foreground(t.Text()). - Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics))) + Render(hintIcon) diagnostics = append(diagnostics, hintStr) return styles.ForceReplaceBackgroundWithLipgloss( - strings.Join(diagnostics, " "), + styles.Padded().Render(strings.Join(diagnostics, " ")), t.BackgroundDarker(), ) } diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go index 77c76584d9b..16fb070e667 100644 --- a/internal/tui/components/dialog/init.go +++ b/internal/tui/components/dialog/init.go @@ -110,7 +110,7 @@ func (m InitDialogCmp) View() string { Foreground(t.Text()). Width(maxWidth). Padding(0, 1). - Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") + Render("Initialization generates a new CONTEXT.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.") question := baseStyle. Foreground(t.Text()). diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go index 8d59f967f0a..a6a39c19895 100644 --- a/internal/tui/components/logs/table.go +++ b/internal/tui/components/logs/table.go @@ -1,7 +1,6 @@ package logs import ( - "encoding/json" "slices" "github.com/charmbracelet/bubbles/key" @@ -10,7 +9,7 @@ import ( "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" + // "github.com/opencode-ai/opencode/internal/tui/styles" "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -66,7 +65,7 @@ func (i *tableCmp) View() string { defaultStyles := table.DefaultStyles() defaultStyles.Selected = defaultStyles.Selected.Foreground(t.Primary()) i.table.SetStyles(defaultStyles) - return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), t.Background()) + return i.table.View() } func (i *tableCmp) GetSize() (int, int) { @@ -76,12 +75,22 @@ func (i *tableCmp) GetSize() (int, int) { func (i *tableCmp) SetSize(width int, height int) tea.Cmd { i.table.SetWidth(width) i.table.SetHeight(height) - cloumns := i.table.Columns() - for i, col := range cloumns { - col.Width = (width / len(cloumns)) - 2 - cloumns[i] = col - } - i.table.SetColumns(cloumns) + columns := i.table.Columns() + + // Calculate widths for visible columns + timeWidth := 8 // Fixed width for Time column + levelWidth := 7 // Fixed width for Level column + + // Message column gets the remaining space + messageWidth := width - timeWidth - levelWidth - 5 // 5 for padding and borders + + // Set column widths + columns[0].Width = 0 // ID column (hidden) + columns[1].Width = timeWidth + columns[2].Width = levelWidth + columns[3].Width = messageWidth + + i.table.SetColumns(columns) return nil } @@ -104,14 +113,12 @@ func (i *tableCmp) setRows() { }) for _, log := range logs { - bm, _ := json.Marshal(log.Attributes) - + // Include ID as hidden first column for selection row := table.Row{ log.ID, log.Time.Format("15:04:05"), log.Level, log.Message, - string(bm), } rows = append(rows, row) } @@ -120,11 +127,10 @@ func (i *tableCmp) setRows() { func NewLogsTable() TableComponent { columns := []table.Column{ - {Title: "ID", Width: 4}, - {Title: "Time", Width: 4}, - {Title: "Level", Width: 10}, - {Title: "Message", Width: 10}, - {Title: "Attributes", Width: 10}, + {Title: "ID", Width: 0}, // ID column with zero width + {Title: "Time", Width: 8}, + {Title: "Level", Width: 7}, + {Title: "Message", Width: 30}, } tableModel := table.New( diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 62a5b9f4f2f..024974e5c26 100644 --- a/internal/tui/page/chat.go +++ b/internal/tui/page/chat.go @@ -2,10 +2,12 @@ package page import ( "context" + "fmt" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/opencode-ai/opencode/internal/app" + "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/session" "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/layout" @@ -64,6 +66,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } p.session = msg + case chat.CompactSessionMsg: + if p.session.ID == "" { + return p, util.ReportWarn("No active session to compact.") + } + + // Run compaction in background + go func(sessionID string) { + err := p.app.CoderAgent.CompactSession(context.Background(), sessionID) + if err != nil { + logging.ErrorPersist(fmt.Sprintf("Compaction failed: %v", err)) + } else { + logging.InfoPersist("Conversation compacted successfully.") + } + }(p.session.ID) + + return p, nil case tea.KeyMsg: switch { case key.Matches(msg, keyMap.NewSession): diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go index 9bd545287f4..c3de8684d06 100644 --- a/internal/tui/page/logs.go +++ b/internal/tui/page/logs.go @@ -7,6 +7,7 @@ import ( "github.com/opencode-ai/opencode/internal/tui/components/logs" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/theme" ) var LogsPage PageID = "logs" @@ -42,11 +43,24 @@ func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (p *logsPage) View() string { - style := styles.BaseStyle().Width(p.width).Height(p.height) - return style.Render(lipgloss.JoinVertical(lipgloss.Top, - p.table.View(), - p.details.View(), - )) + t := theme.CurrentTheme() + + // Add padding to the right of the table view + tableView := lipgloss.NewStyle().PaddingRight(3).Render(p.table.View()) + + return styles.ForceReplaceBackgroundWithLipgloss( + lipgloss.JoinVertical( + lipgloss.Left, + styles.Bold().Render(" esc")+styles.Muted().Render(" to go back"), + "", + lipgloss.JoinHorizontal(lipgloss.Top, + tableView, + p.details.View(), + ), + "", + ), + t.Background(), + ) } func (p *logsPage) BindingKeys() []key.Binding { @@ -63,8 +77,8 @@ func (p *logsPage) SetSize(width int, height int) tea.Cmd { p.width = width p.height = height return tea.Batch( - p.table.SetSize(width, height/2), - p.details.SetSize(width, height/2), + p.table.SetSize(width/2, height-3), + p.details.SetSize(width/2, height-3), ) } @@ -77,7 +91,7 @@ func (p *logsPage) Init() tea.Cmd { func NewLogsPage() LogPage { return &logsPage{ - table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll()), - details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll()), + table: layout.NewContainer(logs.NewLogsTable()), + details: layout.NewContainer(logs.NewLogsDetails()), } } diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index a79ef8bb42d..8a49fce80bb 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -9,3 +9,16 @@ const ( HintIcon string = "ⓗ" SpinnerIcon string = "⟳" ) + +// CircledDigit returns the Unicode circled digit/number for 0‑20. +// out‑of‑range → "". +func CircledDigit(n int) string { + switch { + case n == 0: + return "\u24EA" // ⓪ + case 1 <= n && n <= 20: + return string(rune(0x2460 + n - 1)) // ①–⑳ + default: + return "" + } +} diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go index 1d6cf80d523..b2d83a52309 100644 --- a/internal/tui/styles/styles.go +++ b/internal/tui/styles/styles.go @@ -20,6 +20,10 @@ func Regular() lipgloss.Style { return lipgloss.NewStyle() } +func Muted() lipgloss.Style { + return lipgloss.NewStyle().Foreground(theme.CurrentTheme().TextMuted()) +} + // Bold returns a bold style func Bold() lipgloss.Style { return Regular().Bold(true) @@ -149,4 +153,3 @@ func BorderFocusedColor() lipgloss.AdaptiveColor { func BorderDimColor() lipgloss.AdaptiveColor { return theme.CurrentTheme().BorderDim() } - diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 28f94add791..278252ab93a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -707,14 +707,14 @@ func New(app *app.App) tea.Model { model.RegisterCommand(dialog.Command{ ID: "init", Title: "Initialize Project", - Description: "Create/Update the OpenCode.md memory file", + Description: "Create/Update the CONTEXT.md memory file", Handler: func(cmd dialog.Command) tea.Cmd { - prompt := `Please analyze this codebase and create a OpenCode.md file containing: + prompt := `Please analyze this codebase and create a CONTEXT.md file containing: 1. Build/lint/test commands - especially for running a single test 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. -If there's already a opencode.md, improve it. +If there's already a CONTEXT.md, improve it. If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` return tea.Batch( util.CmdHandler(chat.SendMsg{ @@ -723,5 +723,23 @@ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules ( ) }, }) + + model.RegisterCommand(dialog.Command{ + ID: "compact_conversation", + Title: "Compact Conversation", + Description: "Summarize the current session to save tokens", + Handler: func(cmd dialog.Command) tea.Cmd { + // Get the current session from the appModel + if model.currentPage != page.ChatPage { + return util.ReportWarn("Please navigate to a chat session first.") + } + + // Return a message that will be handled by the chat page + return tea.Batch( + util.CmdHandler(chat.CompactSessionMsg{}), + util.ReportInfo("Compacting conversation...")) + }, + }) + return model }