Compare commits

..
11 Commits
Author SHA1 Message Date
Nicholas HamiltonandGitHub 9345830c8a Fix filepicker manual input (#146)
release / goreleaser (push) Has been cancelled
* fix: allows to type i while manual inputting filepath

* fix: file selection in filepicker focus mode

* remove duplicate code
2025-05-09 16:40:06 +02:00
Kujtim Hoxha 5307100f89 small fix
release / goreleaser (push) Has been cancelled
2025-05-09 16:37:02 +02:00
a58e607c5f feat: custom commands (#133)
release / goreleaser (push) Has been cancelled
* Implement custom commands

* Add User: prefix

* Reuse var

* Check if the agent is busy and if so report a warning

* Update README

* fix typo

* Implement user and project scoped custom commands

* Allow for $ARGUMENTS

* UI tweaks

* Update internal/tui/components/dialog/arguments.go

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>

* Also search in $HOME/.opencode/commands

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-09 16:33:35 +02:00
cd04c44517 replace github.com/google/generative-ai-go with github.com/googleapis/go-genai (#138)
* replace to github.com/googleapis/go-genai

* fix history logic

* small fixes

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-09 14:15:38 +02:00
Joshua LaMorey-SalzmannandGitHub 88711db796 Config fix correcting loose viper string check, default model now set correctly (#147) 2025-05-05 09:40:58 +02:00
phantomreactorandGitHub 9fec8df7d0 add support for images (#144)
release / goreleaser (push) Has been cancelled
2025-05-02 22:23:58 +02:00
Kujtim HoxhaandGitHub 603a3e3c71 add xai support (#135)
release / goreleaser (push) Has been cancelled
2025-05-01 14:17:33 +02:00
Aiden ClineandGitHub e14de7a211 fix: tweak the logic in config to ensure that env vs file configurations merge properly (#115)
release / goreleaser (push) Has been cancelled
2025-05-01 13:22:48 +02:00
Garrett LadleyandGitHub 004cfe7e8e feat: test for getContextFromPaths (#105)
release / goreleaser (push) Has been cancelled
* feat: test for getContextFromPaths

* fix: use testify
2025-05-01 12:55:28 +02:00
AdamandGitHub 58705a1352 fix: more intuitive keybinds (#121) 2025-05-01 12:51:07 +02:00
82de14371d feat: themes (#113)
* feat: themes

* feat: flexoki theme

* feat: onedark theme

* feat: monokai pro theme

* feat: opencode theme (default)

* feat: dracula theme

* feat: tokyonight theme

* feat: tron theme

* some small fixes

---------

Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
2025-05-01 12:49:26 +02:00
134 changed files with 2923 additions and 6927 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- dev
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
+1 -1
View File
@@ -36,5 +36,5 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
+4 -5
View File
@@ -12,7 +12,7 @@ builds:
- amd64
- arm64
ldflags:
- -s -w -X github.com/sst/opencode/internal/version.Version={{.Version}}
- -s -w -X github.com/opencode-ai/opencode/internal/version.Version={{.Version}}
main: ./main.go
archives:
@@ -35,11 +35,10 @@ snapshot:
name_template: "0.0.0-{{ .Timestamp }}"
aurs:
- name: opencode
homepage: "https://github.com/sst/opencode"
homepage: "https://github.com/opencode-ai/opencode"
description: "terminal based agent that can build anything"
maintainers:
- "dax"
- "adam"
- "kujtimiihoxha <kujtimii.h@gmail.com>"
license: "MIT"
private_key: "{{ .Env.AUR_KEY }}"
git_url: "ssh://aur@aur.archlinux.org/opencode-bin.git"
@@ -51,7 +50,7 @@ aurs:
install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"
brews:
- repository:
owner: sst
owner: opencode-ai
name: homebrew-tap
nfpms:
- maintainer: kujtimiihoxha
+6 -1
View File
@@ -1,3 +1,8 @@
{
"$schema": "./opencode-schema.json"
"$schema": "./opencode-schema.json",
"lsp": {
"gopls": {
"command": "gopls"
}
}
}
-24
View File
@@ -1,24 +0,0 @@
# 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
+12 -84
View File
@@ -1,7 +1,5 @@
# ⌬ OpenCode
![OpenCode Terminal UI](screenshot.png)
> **⚠️ Early Development Notice:** This project is in early development and is not yet ready for production use. Features may change, break, or be incomplete. Use at your own risk.
A powerful terminal-based AI assistant for developers, providing intelligent coding assistance directly in your terminal.
@@ -37,7 +35,7 @@ curl -fsSL https://opencode.ai/install | VERSION=0.1.0 bash
### Using Homebrew (macOS and Linux)
```bash
brew install sst/tap/opencode
brew install opencode-ai/tap/opencode
```
### Using AUR (Arch Linux)
@@ -53,7 +51,7 @@ paru -S opencode-bin
### Using Go
```bash
go install github.com/sst/opencode@latest
go install github.com/opencode-ai/opencode@latest
```
## Configuration
@@ -69,7 +67,7 @@ OpenCode looks for configuration in the following locations:
You can configure OpenCode using environment variables:
| Environment Variable | Purpose |
| -------------------------- | ------------------------------------------------------ |
|----------------------------|--------------------------------------------------------|
| `ANTHROPIC_API_KEY` | For Claude models |
| `OPENAI_API_KEY` | For OpenAI models |
| `GEMINI_API_KEY` | For Google Gemini models |
@@ -81,6 +79,7 @@ You can configure OpenCode using environment variables:
| `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) |
| `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models |
### Configuration File Structure
```json
@@ -107,7 +106,7 @@ You can configure OpenCode using environment variables:
}
},
"agents": {
"primary": {
"coder": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000
},
@@ -297,81 +296,12 @@ OpenCode's AI assistant has access to various tools to help with coding tasks:
### Other Tools
| Tool | Description | Parameters |
| ------- | ------------------------------- | ----------------------------------------------------------- |
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
## Theming
OpenCode supports multiple themes for customizing the appearance of the terminal interface.
### Available Themes
The following predefined themes are available:
- `opencode` (default)
- `catppuccin`
- `dracula`
- `flexoki`
- `gruvbox`
- `monokai`
- `onedark`
- `tokyonight`
- `tron`
- `custom` (user-defined)
### Setting a Theme
You can set a theme in your `.opencode.json` configuration file:
```json
{
"tui": {
"theme": "monokai"
}
}
```
### Custom Themes
You can define your own custom theme by setting the `theme` to `"custom"` and providing color definitions in the `customTheme` map:
```json
{
"tui": {
"theme": "custom",
"customTheme": {
"primary": "#ffcc00",
"secondary": "#00ccff",
"accent": { "dark": "#aa00ff", "light": "#ddccff" },
"error": "#ff0000"
}
}
}
```
#### Color Definition Formats
Custom theme colors support two formats:
1. **Simple Hex String**: A single hex color string (e.g., `"#aabbcc"`) that will be used for both light and dark terminal backgrounds.
2. **Adaptive Object**: An object with `dark` and `light` keys, each holding a hex color string. This allows for adaptive colors based on the terminal's background.
#### Available Color Keys
You can define any of the following color keys in your `customTheme`:
- Base colors: `primary`, `secondary`, `accent`
- Status colors: `error`, `warning`, `success`, `info`
- Text colors: `text`, `textMuted`, `textEmphasized`
- Background colors: `background`, `backgroundSecondary`, `backgroundDarker`
- Border colors: `borderNormal`, `borderFocused`, `borderDim`
- Diff view colors: `diffAdded`, `diffRemoved`, `diffContext`, etc.
You don't need to define all colors. Any undefined colors will fall back to the default "opencode" theme colors.
| Tool | Description | Parameters |
| ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- |
| `bash` | Execute shell commands | `command` (required), `timeout` (optional) |
| `fetch` | Fetch data from URLs | `url` (required), `format` (required), `timeout` (optional) |
| `sourcegraph` | Search code across public repositories | `query` (required), `count` (optional), `context_window` (optional), `timeout` (optional) |
| `agent` | Run sub-tasks with the AI agent | `prompt` (required) |
## Architecture
@@ -397,11 +327,9 @@ OpenCode supports custom commands that can be created by users to quickly send p
Custom commands are predefined prompts stored as Markdown files in one of three locations:
1. **User Commands** (prefixed with `user:`):
```
$XDG_CONFIG_HOME/opencode/commands/
```
(typically `~/.config/opencode/commands/` on Linux/macOS)
or
@@ -544,7 +472,7 @@ While the LSP client implementation supports the full LSP protocol (including co
```bash
# Clone the repository
git clone https://github.com/sst/opencode.git
git clone https://github.com/opencode-ai/opencode.git
cd opencode
# Build
+28 -70
View File
@@ -7,42 +7,19 @@ import (
"sync"
"time"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/llm/agent"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/tui"
"github.com/opencode-ai/opencode/internal/version"
"github.com/spf13/cobra"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/discovery"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/version"
)
type SessionIDHandler struct {
slog.Handler
app *app.App
}
func (h *SessionIDHandler) Handle(ctx context.Context, r slog.Record) error {
if h.app != nil {
sessionID := h.app.CurrentSession.ID
if sessionID != "" {
r.AddAttrs(slog.String("session_id", sessionID))
}
}
return h.Handler.Handle(ctx, r)
}
func (h *SessionIDHandler) WithApp(app *app.App) *SessionIDHandler {
h.app = app
return h
}
var rootCmd = &cobra.Command{
Use: "OpenCode",
Short: "A terminal AI assistant for software development",
@@ -60,13 +37,6 @@ to assist developers in writing, debugging, and understanding code directly from
return nil
}
// Setup logging
lvl := new(slog.LevelVar)
textHandler := slog.NewTextHandler(logging.NewSlogWriter(), &slog.HandlerOptions{Level: lvl})
sessionAwareHandler := &SessionIDHandler{Handler: textHandler}
logger := slog.New(sessionAwareHandler)
slog.SetDefault(logger)
// Load the config
debug, _ := cmd.Flags().GetBool("debug")
cwd, _ := cmd.Flags().GetString("cwd")
@@ -83,17 +53,11 @@ to assist developers in writing, debugging, and understanding code directly from
}
cwd = c
}
_, err := config.Load(cwd, debug, lvl)
_, err := config.Load(cwd, debug)
if err != nil {
return err
}
// Run LSP auto-discovery
if err := discovery.IntegrateLSPServers(cwd); err != nil {
slog.Warn("Failed to auto-discover LSP servers", "error", err)
// Continue anyway, this is not a fatal error
}
// Connect DB, this will also run migrations
conn, err := db.Connect()
if err != nil {
@@ -106,10 +70,9 @@ to assist developers in writing, debugging, and understanding code directly from
app, err := app.New(ctx, conn)
if err != nil {
slog.Error("Failed to create app", "error", err)
logging.Error("Failed to create app: %v", err)
return err
}
sessionAwareHandler.WithApp(app)
// Set up the TUI
zone.NewGlobal()
@@ -139,11 +102,11 @@ to assist developers in writing, debugging, and understanding code directly from
for {
select {
case <-tuiCtx.Done():
slog.Info("TUI message handler shutting down")
logging.Info("TUI message handler shutting down")
return
case msg, ok := <-ch:
if !ok {
slog.Info("TUI message channel closed")
logging.Info("TUI message channel closed")
return
}
program.Send(msg)
@@ -153,19 +116,19 @@ to assist developers in writing, debugging, and understanding code directly from
// Cleanup function for when the program exits
cleanup := func() {
// Shutdown the app
app.Shutdown()
// Cancel subscriptions first
cancelSubs()
// Then shutdown the app
app.Shutdown()
// Then cancel TUI message handler
tuiCancel()
// Wait for TUI message handler to finish
tuiWg.Wait()
slog.Info("All goroutines cleaned up")
logging.Info("All goroutines cleaned up")
}
// Run the TUI
@@ -173,18 +136,18 @@ to assist developers in writing, debugging, and understanding code directly from
cleanup()
if err != nil {
slog.Error("TUI error", "error", err)
logging.Error("TUI error: %v", err)
return fmt.Errorf("TUI error: %v", err)
}
slog.Info("TUI exited", "result", result)
logging.Info("TUI exited with result: %v", result)
return nil
},
}
// attemptTUIRecovery tries to recover the TUI after a panic
func attemptTUIRecovery(program *tea.Program) {
slog.Info("Attempting to recover TUI after panic")
logging.Info("Attempting to recover TUI after panic")
// We could try to restart the TUI or gracefully exit
// For now, we'll just quit the program to avoid further issues
@@ -201,7 +164,7 @@ func initMCPTools(ctx context.Context, app *app.App) {
// Set this up once with proper error handling
agent.GetMcpTools(ctxWithTimeout, app.Permissions)
slog.Info("MCP message handling goroutine exiting")
logging.Info("MCP message handling goroutine exiting")
}()
}
@@ -218,16 +181,12 @@ func setupSubscriber[T any](
defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
subCh := subscriber(ctx)
if subCh == nil {
slog.Warn("subscription channel is nil", "name", name)
return
}
for {
select {
case event, ok := <-subCh:
if !ok {
slog.Info("subscription channel closed", "name", name)
logging.Info("subscription channel closed", "name", name)
return
}
@@ -236,13 +195,13 @@ func setupSubscriber[T any](
select {
case outputCh <- msg:
case <-time.After(2 * time.Second):
slog.Warn("message dropped due to slow consumer", "name", name)
logging.Warn("message dropped due to slow consumer", "name", name)
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
logging.Info("subscription cancelled", "name", name)
return
}
case <-ctx.Done():
slog.Info("subscription cancelled", "name", name)
logging.Info("subscription cancelled", "name", name)
return
}
}
@@ -255,14 +214,13 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
wg := sync.WaitGroup{}
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch)
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
cleanupFunc := func() {
slog.Info("Cancelling all subscriptions")
logging.Info("Cancelling all subscriptions")
cancel() // Signal all goroutines to stop
waitCh := make(chan struct{})
@@ -274,10 +232,10 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
select {
case <-waitCh:
slog.Info("All subscription goroutines completed successfully")
logging.Info("All subscription goroutines completed successfully")
close(ch) // Only close after all writers are confirmed done
case <-time.After(5 * time.Second):
slog.Warn("Timed out waiting for some subscription goroutines to complete")
logging.Warn("Timed out waiting for some subscription goroutines to complete")
close(ch)
}
}
+2 -3
View File
@@ -46,7 +46,7 @@ Here's an example configuration that conforms to the schema:
}
},
"agents": {
"primary": {
"coder": {
"model": "claude-3.7-sonnet",
"maxTokens": 5000,
"reasoningEffort": "medium"
@@ -61,5 +61,4 @@ Here's an example configuration that conforms to the schema:
}
}
}
```
```
+3 -31
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"os"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
)
// JSONSchemaType represents a JSON Schema type
@@ -116,34 +116,6 @@ func generateSchema() map[string]any {
"onedark",
"tokyonight",
"tron",
"custom",
},
},
"customTheme": map[string]any{
"type": "object",
"description": "Custom theme color definitions",
"additionalProperties": map[string]any{
"oneOf": []map[string]any{
{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
{
"type": "object",
"properties": map[string]any{
"dark": map[string]any{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
"light": map[string]any{
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$",
},
},
"required": []string{"dark", "light"},
"additionalProperties": false,
},
},
},
},
},
@@ -274,7 +246,7 @@ func generateSchema() map[string]any {
// Add specific agent properties
agentProperties := map[string]any{}
knownAgents := []string{
string(config.AgentPrimary),
string(config.AgentCoder),
string(config.AgentTask),
string(config.AgentTitle),
}
+3 -3
View File
@@ -1,4 +1,4 @@
module github.com/sst/opencode
module github.com/opencode-ai/opencode
go 1.24.0
@@ -35,6 +35,8 @@ require (
require (
cloud.google.com/go v0.116.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
@@ -70,12 +72,10 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
+3 -3
View File
@@ -40,15 +40,15 @@ INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
url="https://github.com/opencode-ai/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/opencode-ai/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
if [[ $? -ne 0 ]]; then
echo "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
url="https://github.com/opencode-ai/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
fi
+32 -65
View File
@@ -7,30 +7,25 @@ import (
"sync"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/llm/agent"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type App struct {
CurrentSession *session.Session
Logs logging.Service
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
Status status.Service
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
PrimaryAgent agent.Service
CoderAgent agent.Service
LSPClients map[string]*lsp.Client
@@ -42,46 +37,17 @@ type App struct {
}
func New(ctx context.Context, conn *sql.DB) (*App, error) {
err := logging.InitService(conn)
if err != nil {
slog.Error("Failed to initialize logging service", "error", err)
return nil, err
}
err = session.InitService(conn)
if err != nil {
slog.Error("Failed to initialize session service", "error", err)
return nil, err
}
err = message.InitService(conn)
if err != nil {
slog.Error("Failed to initialize message service", "error", err)
return nil, err
}
err = history.InitService(conn)
if err != nil {
slog.Error("Failed to initialize history service", "error", err)
return nil, err
}
err = permission.InitService()
if err != nil {
slog.Error("Failed to initialize permission service", "error", err)
return nil, err
}
err = status.InitService()
if err != nil {
slog.Error("Failed to initialize status service", "error", err)
return nil, err
}
q := db.New(conn)
sessions := session.NewService(q)
messages := message.NewService(q)
files := history.NewService(q, conn)
app := &App{
CurrentSession: &session.Session{},
Logs: logging.GetService(),
Sessions: session.GetService(),
Messages: message.GetService(),
History: history.GetService(),
Permissions: permission.GetService(),
Status: status.GetService(),
LSPClients: make(map[string]*lsp.Client),
Sessions: sessions,
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(),
LSPClients: make(map[string]*lsp.Client),
}
// Initialize theme based on configuration
@@ -90,11 +56,12 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
// Initialize LSP clients in the background
go app.initLSPClients(ctx)
app.PrimaryAgent, err = agent.NewAgent(
config.AgentPrimary,
var err error
app.CoderAgent, err = agent.NewAgent(
config.AgentCoder,
app.Sessions,
app.Messages,
agent.PrimaryAgentTools(
agent.CoderAgentTools(
app.Permissions,
app.Sessions,
app.Messages,
@@ -103,7 +70,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) {
),
)
if err != nil {
slog.Error("Failed to create primary agent", "error", err)
logging.Error("Failed to create coder agent", err)
return nil, err
}
@@ -120,9 +87,9 @@ func (app *App) initTheme() {
// Try to set the theme from config
err := theme.SetTheme(cfg.TUI.Theme)
if err != nil {
slog.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
logging.Warn("Failed to set theme from config, using default theme", "theme", cfg.TUI.Theme, "error", err)
} else {
slog.Debug("Set theme from config", "theme", cfg.TUI.Theme)
logging.Debug("Set theme from config", "theme", cfg.TUI.Theme)
}
}
@@ -145,7 +112,7 @@ func (app *App) Shutdown() {
for name, client := range clients {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := client.Shutdown(shutdownCtx); err != nil {
slog.Error("Failed to shutdown LSP client", "name", name, "error", err)
logging.Error("Failed to shutdown LSP client", "name", name, "error", err)
}
cancel()
}
+19 -27
View File
@@ -4,12 +4,10 @@ import (
"context"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/watcher"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/lsp/watcher"
)
func (app *App) initLSPClients(ctx context.Context) {
@@ -20,29 +18,29 @@ func (app *App) initLSPClients(ctx context.Context) {
// Start each client initialization in its own goroutine
go app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
}
slog.Info("LSP clients initialization started in background")
logging.Info("LSP clients initialization started in background")
}
// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher
func (app *App) createAndStartLSPClient(ctx context.Context, name string, command string, args ...string) {
// Create a specific context for initialization with a timeout
slog.Info("Creating LSP client", "name", name, "command", command, "args", args)
logging.Info("Creating LSP client", "name", name, "command", command, "args", args)
// Create the LSP client
lspClient, err := lsp.NewClient(ctx, command, args...)
if err != nil {
slog.Error("Failed to create LSP client for", name, err)
logging.Error("Failed to create LSP client for", name, err)
return
}
// Create a longer timeout for initialization (some servers take time to start)
initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Initialize with the initialization context
_, err = lspClient.InitializeLSPClient(initCtx, config.WorkingDirectory())
if err != nil {
slog.Error("Initialize failed", "name", name, "error", err)
logging.Error("Initialize failed", "name", name, "error", err)
// Clean up the client to prevent resource leaks
lspClient.Close()
return
@@ -50,22 +48,22 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
// Wait for the server to be ready
if err := lspClient.WaitForServerReady(initCtx); err != nil {
slog.Error("Server failed to become ready", "name", name, "error", err)
logging.Error("Server failed to become ready", "name", name, "error", err)
// We'll continue anyway, as some functionality might still work
lspClient.SetServerState(lsp.StateError)
} else {
slog.Info("LSP server is ready", "name", name)
logging.Info("LSP server is ready", "name", name)
lspClient.SetServerState(lsp.StateReady)
}
slog.Info("LSP client initialized", "name", name)
logging.Info("LSP client initialized", "name", name)
// Create a child context that can be canceled when the app is shutting down
watchCtx, cancelFunc := context.WithCancel(ctx)
// Create a context with the server name for better identification
watchCtx = context.WithValue(watchCtx, "serverName", name)
// Create the workspace watcher
workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
@@ -94,7 +92,7 @@ func (app *App) runWorkspaceWatcher(ctx context.Context, name string, workspaceW
})
workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
slog.Info("Workspace watcher stopped", "client", name)
logging.Info("Workspace watcher stopped", "client", name)
}
// restartLSPClient attempts to restart a crashed or failed LSP client
@@ -103,7 +101,7 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
cfg := config.Get()
clientConfig, exists := cfg.LSP[name]
if !exists {
slog.Error("Cannot restart client, configuration not found", "client", name)
logging.Error("Cannot restart client, configuration not found", "client", name)
return
}
@@ -120,15 +118,9 @@ func (app *App) restartLSPClient(ctx context.Context, name string) {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = oldClient.Shutdown(shutdownCtx)
cancel()
// Ensure we close the client to free resources
_ = oldClient.Close()
}
// Wait a moment before restarting to avoid rapid restart cycles
time.Sleep(1 * time.Second)
// Create a new client using the shared function
app.createAndStartLSPClient(ctx, name, clientConfig.Command, clientConfig.Args...)
slog.Info("Successfully restarted LSP client", "client", name)
logging.Info("Successfully restarted LSP client", "client", name)
}
+62 -57
View File
@@ -6,12 +6,12 @@ import (
"fmt"
"log/slog"
"os"
"os/user"
"path/filepath"
"strings"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/spf13/viper"
"github.com/sst/opencode/internal/llm/models"
)
// MCPType defines the type of MCP (Model Control Protocol) server.
@@ -36,9 +36,9 @@ type MCPServer struct {
type AgentName string
const (
AgentPrimary AgentName = "primary"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
AgentCoder AgentName = "coder"
AgentTask AgentName = "task"
AgentTitle AgentName = "title"
)
// Agent defines configuration for different LLM models and their token limits.
@@ -69,8 +69,7 @@ type LSPConfig struct {
// TUIConfig defines the configuration for the Terminal User Interface.
type TUIConfig struct {
Theme string `json:"theme,omitempty"`
CustomTheme map[string]any `json:"customTheme,omitempty"`
Theme string `json:"theme,omitempty"`
}
// Config is the main configuration structure for the application.
@@ -102,8 +101,6 @@ var defaultContextPaths = []string{
".cursor/rules/",
"CLAUDE.md",
"CLAUDE.local.md",
"CONTEXT.md",
"CONTEXT.local.md",
"opencode.md",
"opencode.local.md",
"OpenCode.md",
@@ -118,7 +115,7 @@ var cfg *Config
// Load initializes the configuration from environment variables and config files.
// If debug is true, debug mode is enabled and log level is set to debug.
// It returns an error if configuration loading fails.
func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
func Load(workingDir string, debug bool) (*Config, error) {
if cfg != nil {
return cfg, nil
}
@@ -149,13 +146,39 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
}
applyDefaultValues()
defaultLevel := slog.LevelInfo
if cfg.Debug {
defaultLevel = slog.LevelDebug
}
lvl.Set(defaultLevel)
slog.SetLogLoggerLevel(defaultLevel)
if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
// if file does not exist create it
if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
return cfg, fmt.Errorf("failed to create directory: %w", err)
}
if _, err := os.Create(loggingFile); err != nil {
return cfg, fmt.Errorf("failed to create log file: %w", err)
}
}
sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
return cfg, fmt.Errorf("failed to open log file: %w", err)
}
// Configure logger
logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
Level: defaultLevel,
}))
slog.SetDefault(logger)
} else {
// Configure logger
logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
Level: defaultLevel,
}))
slog.SetDefault(logger)
}
// Validate configuration
if err := Validate(); err != nil {
@@ -238,7 +261,7 @@ func setProviderDefaults() {
// Anthropic configuration
if key := viper.GetString("providers.anthropic.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Claude37Sonnet)
viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
viper.SetDefault("agents.task.model", models.Claude37Sonnet)
viper.SetDefault("agents.title.model", models.Claude37Sonnet)
return
@@ -246,7 +269,7 @@ func setProviderDefaults() {
// OpenAI configuration
if key := viper.GetString("providers.openai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.GPT41)
viper.SetDefault("agents.coder.model", models.GPT41)
viper.SetDefault("agents.task.model", models.GPT41Mini)
viper.SetDefault("agents.title.model", models.GPT41Mini)
return
@@ -254,7 +277,7 @@ func setProviderDefaults() {
// Google Gemini configuration
if key := viper.GetString("providers.gemini.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.Gemini25)
viper.SetDefault("agents.coder.model", models.Gemini25)
viper.SetDefault("agents.task.model", models.Gemini25Flash)
viper.SetDefault("agents.title.model", models.Gemini25Flash)
return
@@ -262,7 +285,7 @@ func setProviderDefaults() {
// Groq configuration
if key := viper.GetString("providers.groq.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.QWENQwq)
viper.SetDefault("agents.coder.model", models.QWENQwq)
viper.SetDefault("agents.task.model", models.QWENQwq)
viper.SetDefault("agents.title.model", models.QWENQwq)
return
@@ -270,7 +293,7 @@ func setProviderDefaults() {
// OpenRouter configuration
if key := viper.GetString("providers.openrouter.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.coder.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.task.model", models.OpenRouterClaude37Sonnet)
viper.SetDefault("agents.title.model", models.OpenRouterClaude35Haiku)
return
@@ -278,7 +301,7 @@ func setProviderDefaults() {
// XAI configuration
if key := viper.GetString("providers.xai.apiKey"); strings.TrimSpace(key) != "" {
viper.SetDefault("agents.primary.model", models.XAIGrok3Beta)
viper.SetDefault("agents.coder.model", models.XAIGrok3Beta)
viper.SetDefault("agents.task.model", models.XAIGrok3Beta)
viper.SetDefault("agents.title.model", models.XAiGrok3MiniFastBeta)
return
@@ -286,7 +309,7 @@ func setProviderDefaults() {
// AWS Bedrock configuration
if hasAWSCredentials() {
viper.SetDefault("agents.primary.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
return
@@ -294,7 +317,7 @@ func setProviderDefaults() {
// Azure OpenAI configuration
if os.Getenv("AZURE_OPENAI_ENDPOINT") != "" {
viper.SetDefault("agents.primary.model", models.AzureGPT41)
viper.SetDefault("agents.coder.model", models.AzureGPT41)
viper.SetDefault("agents.task.model", models.AzureGPT41Mini)
viper.SetDefault("agents.title.model", models.AzureGPT41Mini)
return
@@ -370,13 +393,13 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Check if model exists
model, modelExists := models.SupportedModels[agent.Model]
if !modelExists {
slog.Warn("unsupported model configured, reverting to default",
logging.Warn("unsupported model configured, reverting to default",
"agent", name,
"configured_model", agent.Model)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
@@ -391,14 +414,14 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Provider not configured, check if we have environment variables
apiKey := getProviderAPIKey(provider)
if apiKey == "" {
slog.Warn("provider not configured for model, reverting to default",
logging.Warn("provider not configured for model, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
@@ -407,18 +430,18 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
cfg.Providers[provider] = Provider{
APIKey: apiKey,
}
slog.Info("added provider from environment", "provider", provider)
logging.Info("added provider from environment", "provider", provider)
}
} else if providerCfg.Disabled || providerCfg.APIKey == "" {
// Provider is disabled or has no API key
slog.Warn("provider is disabled or has no API key, reverting to default",
logging.Warn("provider is disabled or has no API key, reverting to default",
"agent", name,
"model", agent.Model,
"provider", provider)
// Set default model based on available providers
if setDefaultModelForAgent(name) {
slog.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
} else {
return fmt.Errorf("no valid provider available for agent %s", name)
}
@@ -426,7 +449,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Validate max tokens
if agent.MaxTokens <= 0 {
slog.Warn("invalid max tokens, setting to default",
logging.Warn("invalid max tokens, setting to default",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens)
@@ -441,7 +464,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
cfg.Agents[name] = updatedAgent
} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
// Ensure max tokens doesn't exceed half the context window (reasonable limit)
slog.Warn("max tokens exceeds half the context window, adjusting",
logging.Warn("max tokens exceeds half the context window, adjusting",
"agent", name,
"model", agent.Model,
"max_tokens", agent.MaxTokens,
@@ -457,7 +480,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
if model.CanReason && provider == models.ProviderOpenAI {
if agent.ReasoningEffort == "" {
// Set default reasoning effort for models that support it
slog.Info("setting default reasoning effort for model that supports reasoning",
logging.Info("setting default reasoning effort for model that supports reasoning",
"agent", name,
"model", agent.Model)
@@ -469,7 +492,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
// Check if reasoning effort is valid (low, medium, high)
effort := strings.ToLower(agent.ReasoningEffort)
if effort != "low" && effort != "medium" && effort != "high" {
slog.Warn("invalid reasoning effort, setting to medium",
logging.Warn("invalid reasoning effort, setting to medium",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
@@ -482,7 +505,7 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error {
}
} else if !model.CanReason && agent.ReasoningEffort != "" {
// Model doesn't support reasoning but reasoning effort is set
slog.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
"agent", name,
"model", agent.Model,
"reasoning_effort", agent.ReasoningEffort)
@@ -512,7 +535,7 @@ func Validate() error {
// Validate providers
for provider, providerCfg := range cfg.Providers {
if providerCfg.APIKey == "" && !providerCfg.Disabled {
slog.Warn("provider has no API key, marking as disabled", "provider", provider)
logging.Warn("provider has no API key, marking as disabled", "provider", provider)
providerCfg.Disabled = true
cfg.Providers[provider] = providerCfg
}
@@ -521,7 +544,7 @@ func Validate() error {
// Validate LSP configurations
for language, lspConfig := range cfg.LSP {
if lspConfig.Command == "" && !lspConfig.Disabled {
slog.Warn("LSP configuration has no command, marking as disabled", "language", language)
logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
lspConfig.Disabled = true
cfg.LSP[language] = lspConfig
}
@@ -686,24 +709,6 @@ func WorkingDirectory() string {
return cfg.WorkingDir
}
// GetHostname returns the system hostname or "User" if it can't be determined
func GetHostname() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "User", err
}
return hostname, nil
}
// GetUsername returns the current user's username
func GetUsername() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "User", err
}
return currentUser.Username, nil
}
func UpdateAgentModel(agentName AgentName, modelID models.ModelID) error {
if cfg == nil {
panic("config not loaded")
@@ -755,7 +760,7 @@ func UpdateTheme(themeName string) error {
return fmt.Errorf("failed to get home directory: %w", err)
}
configFile = filepath.Join(homeDir, fmt.Sprintf(".%s.json", appName))
slog.Info("config file not found, creating new one", "path", configFile)
logging.Info("config file not found, creating new one", "path", configFile)
configData = []byte(`{}`)
} else {
// Read the existing config file
@@ -767,16 +772,16 @@ func UpdateTheme(themeName string) error {
}
// Parse the JSON
var configMap map[string]any
var configMap map[string]interface{}
if err := json.Unmarshal(configData, &configMap); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Update just the theme value
tuiConfig, ok := configMap["tui"].(map[string]any)
tuiConfig, ok := configMap["tui"].(map[string]interface{})
if !ok {
// TUI config doesn't exist yet, create it
configMap["tui"] = map[string]any{"theme": themeName}
configMap["tui"] = map[string]interface{}{"theme": themeName}
} else {
// Update existing TUI config
tuiConfig["theme"] = themeName
+1
View File
@@ -58,3 +58,4 @@ func MarkProjectInitialized() error {
return nil
}
+6 -6
View File
@@ -9,8 +9,8 @@ import (
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/sst/opencode/internal/config"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/pressly/goose/v3"
)
@@ -47,21 +47,21 @@ func Connect() (*sql.DB, error) {
for _, pragma := range pragmas {
if _, err = db.Exec(pragma); err != nil {
slog.Error("Failed to set pragma", pragma, err)
logging.Error("Failed to set pragma", pragma, err)
} else {
slog.Debug("Set pragma", "pragma", pragma)
logging.Debug("Set pragma", "pragma", pragma)
}
}
goose.SetBaseFS(FS)
if err := goose.SetDialect("sqlite3"); err != nil {
slog.Error("Failed to set dialect", "error", err)
logging.Error("Failed to set dialect", "error", err)
return nil, fmt.Errorf("failed to set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
slog.Error("Failed to apply migrations", "error", err)
logging.Error("Failed to apply migrations", "error", err)
return nil, fmt.Errorf("failed to apply migrations: %w", err)
}
return db, nil
+47 -87
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
@@ -27,9 +27,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.createFileStmt, err = db.PrepareContext(ctx, createFile); err != nil {
return nil, fmt.Errorf("error preparing query CreateFile: %w", err)
}
if q.createLogStmt, err = db.PrepareContext(ctx, createLog); err != nil {
return nil, fmt.Errorf("error preparing query CreateLog: %w", err)
}
if q.createMessageStmt, err = db.PrepareContext(ctx, createMessage); err != nil {
return nil, fmt.Errorf("error preparing query CreateMessage: %w", err)
}
@@ -63,9 +60,6 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.getSessionByIDStmt, err = db.PrepareContext(ctx, getSessionByID); err != nil {
return nil, fmt.Errorf("error preparing query GetSessionByID: %w", err)
}
if q.listAllLogsStmt, err = db.PrepareContext(ctx, listAllLogs); err != nil {
return nil, fmt.Errorf("error preparing query ListAllLogs: %w", err)
}
if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil {
return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err)
}
@@ -75,15 +69,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listLatestSessionFilesStmt, err = db.PrepareContext(ctx, listLatestSessionFiles); err != nil {
return nil, fmt.Errorf("error preparing query ListLatestSessionFiles: %w", err)
}
if q.listLogsBySessionStmt, err = db.PrepareContext(ctx, listLogsBySession); err != nil {
return nil, fmt.Errorf("error preparing query ListLogsBySession: %w", err)
}
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)
}
@@ -109,11 +97,6 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing createFileStmt: %w", cerr)
}
}
if q.createLogStmt != nil {
if cerr := q.createLogStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing createLogStmt: %w", cerr)
}
}
if q.createMessageStmt != nil {
if cerr := q.createMessageStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing createMessageStmt: %w", cerr)
@@ -169,11 +152,6 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing getSessionByIDStmt: %w", cerr)
}
}
if q.listAllLogsStmt != nil {
if cerr := q.listAllLogsStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listAllLogsStmt: %w", cerr)
}
}
if q.listFilesByPathStmt != nil {
if cerr := q.listFilesByPathStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr)
@@ -189,21 +167,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listLatestSessionFilesStmt: %w", cerr)
}
}
if q.listLogsBySessionStmt != nil {
if cerr := q.listLogsBySessionStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listLogsBySessionStmt: %w", cerr)
}
}
if q.listMessagesBySessionStmt != nil {
if cerr := q.listMessagesBySessionStmt.Close(); cerr != nil {
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)
@@ -266,63 +234,55 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar
}
type Queries struct {
db DBTX
tx *sql.Tx
createFileStmt *sql.Stmt
createLogStmt *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
listAllLogsStmt *sql.Stmt
listFilesByPathStmt *sql.Stmt
listFilesBySessionStmt *sql.Stmt
listLatestSessionFilesStmt *sql.Stmt
listLogsBySessionStmt *sql.Stmt
listMessagesBySessionStmt *sql.Stmt
listMessagesBySessionAfterStmt *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
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,
createLogStmt: q.createLogStmt,
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,
listAllLogsStmt: q.listAllLogsStmt,
listFilesByPathStmt: q.listFilesByPathStmt,
listFilesBySessionStmt: q.listFilesBySessionStmt,
listLatestSessionFilesStmt: q.listLatestSessionFilesStmt,
listLogsBySessionStmt: q.listLogsBySessionStmt,
listMessagesBySessionStmt: q.listMessagesBySessionStmt,
listMessagesBySessionAfterStmt: q.listMessagesBySessionAfterStmt,
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,
listNewFilesStmt: q.listNewFilesStmt,
listSessionsStmt: q.listSessionsStmt,
updateFileStmt: q.updateFileStmt,
updateMessageStmt: q.updateMessageStmt,
updateSessionStmt: q.updateSessionStmt,
}
}
+14 -20
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: files.sql
package db
@@ -15,11 +15,13 @@ INSERT INTO files (
session_id,
path,
content,
version
version,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
RETURNING id, session_id, path, content, version, created_at, updated_at
`
type CreateFileParams struct {
@@ -45,7 +47,6 @@ func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, e
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -73,7 +74,7 @@ func (q *Queries) DeleteSessionFiles(ctx context.Context, sessionID string) erro
}
const getFile = `-- name: GetFile :one
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE id = ? LIMIT 1
`
@@ -87,7 +88,6 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -95,7 +95,7 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
}
const getFileByPathAndSession = `-- name: GetFileByPathAndSession :one
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE path = ? AND session_id = ?
ORDER BY created_at DESC
@@ -116,7 +116,6 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -124,7 +123,7 @@ func (q *Queries) GetFileByPathAndSession(ctx context.Context, arg GetFileByPath
}
const listFilesByPath = `-- name: ListFilesByPath :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE path = ?
ORDER BY created_at DESC
@@ -145,7 +144,6 @@ func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, err
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -163,7 +161,7 @@ func (q *Queries) ListFilesByPath(ctx context.Context, path string) ([]File, err
}
const listFilesBySession = `-- name: ListFilesBySession :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE session_id = ?
ORDER BY created_at ASC
@@ -184,7 +182,6 @@ func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]F
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -202,7 +199,7 @@ func (q *Queries) ListFilesBySession(ctx context.Context, sessionID string) ([]F
}
const listLatestSessionFiles = `-- name: ListLatestSessionFiles :many
SELECT f.id, f.session_id, f.path, f.content, f.version, f.is_new, f.created_at, f.updated_at
SELECT f.id, f.session_id, f.path, f.content, f.version, f.created_at, f.updated_at
FROM files f
INNER JOIN (
SELECT path, MAX(created_at) as max_created_at
@@ -228,7 +225,6 @@ func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string)
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -246,7 +242,7 @@ func (q *Queries) ListLatestSessionFiles(ctx context.Context, sessionID string)
}
const listNewFiles = `-- name: ListNewFiles :many
SELECT id, session_id, path, content, version, is_new, created_at, updated_at
SELECT id, session_id, path, content, version, created_at, updated_at
FROM files
WHERE is_new = 1
ORDER BY created_at DESC
@@ -267,7 +263,6 @@ func (q *Queries) ListNewFiles(ctx context.Context) ([]File, error) {
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -289,9 +284,9 @@ UPDATE files
SET
content = ?,
version = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
RETURNING id, session_id, path, content, version, is_new, created_at, updated_at
RETURNING id, session_id, path, content, version, created_at, updated_at
`
type UpdateFileParams struct {
@@ -309,7 +304,6 @@ func (q *Queries) UpdateFile(ctx context.Context, arg UpdateFileParams) (File, e
&i.Path,
&i.Content,
&i.Version,
&i.IsNew,
&i.CreatedAt,
&i.UpdatedAt,
)
-137
View File
@@ -1,137 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: logs.sql
package db
import (
"context"
"database/sql"
)
const createLog = `-- name: CreateLog :one
INSERT INTO logs (
id,
session_id,
timestamp,
level,
message,
attributes
) VALUES (
?,
?,
?,
?,
?,
?
) RETURNING id, session_id, timestamp, level, message, attributes, created_at, updated_at
`
type CreateLogParams struct {
ID string `json:"id"`
SessionID sql.NullString `json:"session_id"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Attributes sql.NullString `json:"attributes"`
}
func (q *Queries) CreateLog(ctx context.Context, arg CreateLogParams) (Log, error) {
row := q.queryRow(ctx, q.createLogStmt, createLog,
arg.ID,
arg.SessionID,
arg.Timestamp,
arg.Level,
arg.Message,
arg.Attributes,
)
var i Log
err := row.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listAllLogs = `-- name: ListAllLogs :many
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
ORDER BY timestamp DESC
LIMIT ?
`
func (q *Queries) ListAllLogs(ctx context.Context, limit int64) ([]Log, error) {
rows, err := q.query(ctx, q.listAllLogsStmt, listAllLogs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Log{}
for rows.Next() {
var i Log
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listLogsBySession = `-- name: ListLogsBySession :many
SELECT id, session_id, timestamp, level, message, attributes, created_at, updated_at FROM logs
WHERE session_id = ?
ORDER BY timestamp DESC
`
func (q *Queries) ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, error) {
rows, err := q.query(ctx, q.listLogsBySessionStmt, listLogsBySession, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Log{}
for rows.Next() {
var i Log
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.Timestamp,
&i.Level,
&i.Message,
&i.Attributes,
&i.CreatedAt,
&i.UpdatedAt,
); 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
}
+9 -51
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: messages.sql
package db
@@ -16,9 +16,11 @@ INSERT INTO messages (
session_id,
role,
parts,
model
model,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING id, session_id, role, parts, model, created_at, updated_at, finished_at
`
@@ -134,63 +136,19 @@ 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 string `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
parts = ?,
finished_at = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
`
type UpdateMessageParams struct {
Parts string `json:"parts"`
FinishedAt sql.NullString `json:"finished_at"`
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 {
@@ -6,19 +6,17 @@ CREATE TABLE IF NOT EXISTS sessions (
parent_session_id TEXT,
title TEXT NOT NULL,
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0),
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens>= 0),
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
summary TEXT,
summarized_at TEXT,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
updated_at INTEGER NOT NULL, -- Unix timestamp in milliseconds
created_at INTEGER NOT NULL -- Unix timestamp in milliseconds
);
CREATE TRIGGER IF NOT EXISTS update_sessions_updated_at
AFTER UPDATE ON sessions
BEGIN
UPDATE sessions SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
UPDATE sessions SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
@@ -29,9 +27,8 @@ CREATE TABLE IF NOT EXISTS files (
path TEXT NOT NULL,
content TEXT NOT NULL,
version TEXT NOT NULL,
is_new INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
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,
UNIQUE(path, session_id, version)
);
@@ -42,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_files_path ON files (path);
CREATE TRIGGER IF NOT EXISTS update_files_updated_at
AFTER UPDATE ON files
BEGIN
UPDATE files SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
UPDATE files SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
@@ -53,9 +50,9 @@ CREATE TABLE IF NOT EXISTS messages (
role TEXT NOT NULL,
parts TEXT NOT NULL default '[]',
model TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
finished_at 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
);
@@ -64,7 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages (session_id);
CREATE TRIGGER IF NOT EXISTS update_messages_updated_at
AFTER UPDATE ON messages
BEGIN
UPDATE messages SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
UPDATE messages SET updated_at = strftime('%s', 'now')
WHERE id = new.id;
END;
@@ -84,28 +81,6 @@ UPDATE sessions SET
WHERE id = old.session_id;
END;
-- Logs
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
attributes TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f000Z', 'now'))
);
CREATE INDEX logs_session_id_idx ON logs(session_id);
CREATE INDEX logs_timestamp_idx ON logs(timestamp);
CREATE TRIGGER IF NOT EXISTS update_logs_updated_at
AFTER UPDATE ON logs
BEGIN
UPDATE logs SET updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
WHERE id = new.id;
END;
-- +goose StatementEnd
-- +goose Down
@@ -113,13 +88,11 @@ END;
DROP TRIGGER IF EXISTS update_sessions_updated_at;
DROP TRIGGER IF EXISTS update_messages_updated_at;
DROP TRIGGER IF EXISTS update_files_updated_at;
DROP TRIGGER IF EXISTS update_logs_updated_at;
DROP TRIGGER IF EXISTS update_session_message_count_on_delete;
DROP TRIGGER IF EXISTS update_session_message_count_on_insert;
DROP TABLE IF EXISTS logs;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS sessions;
-- +goose StatementEnd
+13 -27
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
@@ -9,25 +9,13 @@ import (
)
type File struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Path string `json:"path"`
Content string `json:"content"`
Version string `json:"version"`
IsNew sql.NullInt64 `json:"is_new"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type Log struct {
ID string `json:"id"`
SessionID sql.NullString `json:"session_id"`
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Attributes sql.NullString `json:"attributes"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"`
SessionID string `json:"session_id"`
Path string `json:"path"`
Content string `json:"content"`
Version string `json:"version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Message struct {
@@ -36,9 +24,9 @@ type Message struct {
Role string `json:"role"`
Parts string `json:"parts"`
Model sql.NullString `json:"model"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
FinishedAt sql.NullString `json:"finished_at"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
FinishedAt sql.NullInt64 `json:"finished_at"`
}
type Session struct {
@@ -49,8 +37,6 @@ type Session struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
Summary sql.NullString `json:"summary"`
SummarizedAt sql.NullString `json:"summarized_at"`
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CreatedAt int64 `json:"created_at"`
}
+1 -6
View File
@@ -1,17 +1,15 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
package db
import (
"context"
"database/sql"
)
type Querier interface {
CreateFile(ctx context.Context, arg CreateFileParams) (File, error)
CreateLog(ctx context.Context, arg CreateLogParams) (Log, error)
CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error)
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteFile(ctx context.Context, id string) error
@@ -23,13 +21,10 @@ type Querier interface {
GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error)
GetMessage(ctx context.Context, id string) (Message, error)
GetSessionByID(ctx context.Context, id string) (Session, error)
ListAllLogs(ctx context.Context, limit int64) ([]Log, error)
ListFilesByPath(ctx context.Context, path string) ([]File, error)
ListFilesBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
ListLogsBySession(ctx context.Context, sessionID sql.NullString) ([]Log, 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)
+15 -33
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// sqlc v1.27.0
// source: sessions.sql
package db
@@ -19,8 +19,8 @@ INSERT INTO sessions (
prompt_tokens,
completion_tokens,
cost,
summary,
summarized_at
updated_at,
created_at
) VALUES (
?,
?,
@@ -29,9 +29,9 @@ INSERT INTO sessions (
?,
?,
?,
?,
?
) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
strftime('%s', 'now'),
strftime('%s', 'now')
) RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
`
type CreateSessionParams struct {
@@ -42,8 +42,6 @@ type CreateSessionParams struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
Summary sql.NullString `json:"summary"`
SummarizedAt sql.NullString `json:"summarized_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
@@ -55,8 +53,6 @@ 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(
@@ -67,8 +63,6 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
@@ -86,7 +80,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, summary, summarized_at, updated_at, created_at
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
FROM sessions
WHERE id = ? LIMIT 1
`
@@ -102,8 +96,6 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
@@ -111,7 +103,7 @@ func (q *Queries) GetSessionByID(ctx context.Context, id string) (Session, error
}
const listSessions = `-- name: ListSessions :many
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
FROM sessions
WHERE parent_session_id is NULL
ORDER BY created_at DESC
@@ -134,8 +126,6 @@ func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) {
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
); err != nil {
@@ -158,21 +148,17 @@ SET
title = ?,
prompt_tokens = ?,
completion_tokens = ?,
cost = ?,
summary = ?,
summarized_at = ?
cost = ?
WHERE id = ?
RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary, summarized_at, updated_at, created_at
RETURNING id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at
`
type UpdateSessionParams struct {
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.NullString `json:"summarized_at"`
ID string `json:"id"`
Title string `json:"title"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Cost float64 `json:"cost"`
ID string `json:"id"`
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
@@ -181,8 +167,6 @@ 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
@@ -194,8 +178,6 @@ func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (S
&i.PromptTokens,
&i.CompletionTokens,
&i.Cost,
&i.Summary,
&i.SummarizedAt,
&i.UpdatedAt,
&i.CreatedAt,
)
+5 -3
View File
@@ -28,9 +28,11 @@ INSERT INTO files (
session_id,
path,
content,
version
version,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING *;
@@ -39,7 +41,7 @@ UPDATE files
SET
content = ?,
version = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?
RETURNING *;
-26
View File
@@ -1,26 +0,0 @@
-- name: CreateLog :one
INSERT INTO logs (
id,
session_id,
timestamp,
level,
message,
attributes
) VALUES (
?,
?,
?,
?,
?,
?
) RETURNING *;
-- name: ListLogsBySession :many
SELECT * FROM logs
WHERE session_id = ?
ORDER BY timestamp DESC;
-- name: ListAllLogs :many
SELECT * FROM logs
ORDER BY timestamp DESC
LIMIT ?;
+5 -9
View File
@@ -9,21 +9,17 @@ 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,
session_id,
role,
parts,
model
model,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?
?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING *;
@@ -32,7 +28,7 @@ UPDATE messages
SET
parts = ?,
finished_at = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%f000Z', 'now')
updated_at = strftime('%s', 'now')
WHERE id = ?;
+5 -7
View File
@@ -7,8 +7,8 @@ INSERT INTO sessions (
prompt_tokens,
completion_tokens,
cost,
summary,
summarized_at
updated_at,
created_at
) VALUES (
?,
?,
@@ -17,8 +17,8 @@ INSERT INTO sessions (
?,
?,
?,
?,
?
strftime('%s', 'now'),
strftime('%s', 'now')
) RETURNING *;
-- name: GetSessionByID :one
@@ -38,9 +38,7 @@ SET
title = ?,
prompt_tokens = ?,
completion_tokens = ?,
cost = ?,
summary = ?,
summarized_at = ?
cost = ?
WHERE id = ?
RETURNING *;
+98 -94
View File
@@ -15,9 +15,9 @@ import (
"github.com/aymanbagabas/go-udiff"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/sergi/go-diff/diffmatchpatch"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/theme"
)
// -------------------------------------------------------------------------
@@ -563,6 +563,18 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
// Rendering Functions
// -------------------------------------------------------------------------
func lipglossToHex(color lipgloss.Color) string {
r, g, b, a := color.RGBA()
// Scale uint32 values (0-65535) to uint8 (0-255).
r8 := uint8(r >> 8)
g8 := uint8(g >> 8)
b8 := uint8(b >> 8)
a8 := uint8(a >> 8)
return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
}
// applyHighlighting applies intra-line highlighting to a piece of text
func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg lipgloss.AdaptiveColor) string {
// Find all ANSI sequences in the content
@@ -648,9 +660,8 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
r, g, b, _ = bgColor.RGBA()
sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
sb.WriteString(char)
// Full reset of all attributes to ensure clean state
sb.WriteString("\x1b[0m")
// Reset foreground and background
sb.WriteString("\x1b[39m")
// Reapply the original ANSI sequence
sb.WriteString(currentStyle)
@@ -666,98 +677,52 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
return sb.String()
}
// renderDiffColumnLine is a helper function that handles the common logic for rendering diff columns
func renderDiffColumnLine(
fileName string,
dl *DiffLine,
colWidth int,
isLeftColumn bool,
t theme.Theme,
) string {
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
t := theme.CurrentTheme()
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
removedLineStyle, _, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type and column
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
var lineNum string
var highlightType LineType
var highlightColor lipgloss.AdaptiveColor
if isLeftColumn {
// Left column logic
switch dl.Kind {
case LineRemoved:
marker = "-"
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
highlightType = LineRemoved
highlightColor = t.DiffHighlightRemoved()
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for left column
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
} else {
// Right column logic
switch dl.Kind {
case LineAdded:
marker = "+"
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
highlightType = LineAdded
highlightColor = t.DiffHighlightAdded()
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = " "
bgStyle = contextLineStyle
}
// Format line number for right column
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
}
// Style the marker based on line type
var styledMarker string
switch dl.Kind {
case LineRemoved:
styledMarker = removedLineStyle.Foreground(t.DiffRemoved()).Render(marker)
marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
bgStyle = removedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
case LineAdded:
styledMarker = addedLineStyle.Foreground(t.DiffAdded()).Render(marker)
marker = "?"
bgStyle = contextLineStyle
case LineContext:
styledMarker = contextLineStyle.Foreground(t.TextMuted()).Render(marker)
default:
styledMarker = marker
marker = contextLineStyle.Render(" ")
bgStyle = contextLineStyle
}
// Format line number
lineNum := ""
if dl.OldLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.OldLineNo)
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + styledMarker)
prefix := lineNumberStyle.Render(lineNum + " " + marker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting if needed
if (dl.Kind == LineRemoved && isLeftColumn || dl.Kind == LineAdded && !isLeftColumn) && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, highlightType, highlightColor)
// Apply intra-line highlighting for removed lines
if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
}
// Add a padding space for added/removed lines
if (dl.Kind == LineRemoved && isLeftColumn) || (dl.Kind == LineAdded && !isLeftColumn) {
// Add a padding space for removed lines
if dl.Kind == LineRemoved {
content = bgStyle.Render(" ") + content
}
@@ -772,14 +737,64 @@ func renderDiffColumnLine(
)
}
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, true, theme.CurrentTheme())
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
return renderDiffColumnLine(fileName, dl, colWidth, false, theme.CurrentTheme())
t := theme.CurrentTheme()
if dl == nil {
contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
return contextLineStyle.Width(colWidth).Render("")
}
_, addedLineStyle, contextLineStyle, lineNumberStyle := createStyles(t)
// Determine line style based on line type
var marker string
var bgStyle lipgloss.Style
switch dl.Kind {
case LineAdded:
marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
bgStyle = addedLineStyle
lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
case LineContext:
marker = contextLineStyle.Render(" ")
bgStyle = contextLineStyle
}
// Format line number
lineNum := ""
if dl.NewLineNo > 0 {
lineNum = fmt.Sprintf("%6d", dl.NewLineNo)
}
// Create the line prefix
prefix := lineNumberStyle.Render(lineNum + " " + marker)
// Apply syntax highlighting
content := highlightLine(fileName, dl.Content, bgStyle.GetBackground())
// Apply intra-line highlighting for added lines
if dl.Kind == LineAdded && len(dl.Segments) > 0 {
content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
}
// Add a padding space for added lines
if dl.Kind == LineAdded {
content = bgStyle.Render(" ") + content
}
// Create the final line and truncate if needed
lineText := prefix + content
return bgStyle.MaxHeight(1).Width(colWidth).Render(
ansi.Truncate(
lineText,
colWidth,
lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
),
)
}
// -------------------------------------------------------------------------
@@ -818,22 +833,13 @@ func RenderSideBySideHunk(fileName string, h Hunk, opts ...SideBySideOption) str
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(diffText string, opts ...SideBySideOption) (string, error) {
t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
config := NewSideBySideConfig(opts...)
for _, h := range diffResult.Hunks {
sb.WriteString(
lipgloss.NewStyle().
Background(t.DiffHunkHeader()).
Foreground(t.Background()).
Width(config.TotalWidth).
Render(h.Header) + "\n",
)
sb.WriteString(RenderSideBySideHunk(diffResult.OldFile, h, opts...))
}
@@ -848,10 +854,8 @@ func GenerateDiff(beforeContent, afterContent, fileName string) (string, int, in
fileName = strings.TrimPrefix(fileName, cwd)
fileName = strings.TrimPrefix(fileName, "/")
edits := udiff.Strings(beforeContent, afterContent)
unified, _ := udiff.ToUnified("a/"+fileName, "b/"+fileName, beforeContent, edits, 8)
var (
unified = udiff.Unified("a/"+fileName, "b/"+fileName, beforeContent, afterContent)
additions = 0
removals = 0
)
-103
View File
@@ -1,103 +0,0 @@
package diff
import (
"fmt"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/stretchr/testify/assert"
)
// TestApplyHighlighting tests the applyHighlighting function with various ANSI sequences
func TestApplyHighlighting(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
// Test cases
tests := []struct {
name string
content string
segments []Segment
segmentType LineType
expectContains string
}{
{
name: "Simple text with no ANSI",
content: "This is a test",
segments: []Segment{{Start: 0, End: 4, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI foreground",
content: "This \x1b[32mis\x1b[0m a test", // "is" in green
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with existing ANSI background",
content: "This \x1b[42mis\x1b[0m a test", // "is" with green background
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
{
name: "Text with complex ANSI styling",
content: "This \x1b[1;32;45mis\x1b[0m a test", // "is" bold green on magenta
segments: []Segment{{Start: 5, End: 7, Type: LineAdded}},
segmentType: LineAdded,
// Should contain full reset sequence after highlighting
expectContains: "\x1b[0m",
},
}
for _, tc := range tests {
tc := tc // Capture range variable for parallel testing
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg)
// Verify the result contains the expected sequence
assert.Contains(t, result, tc.expectContains,
"Result should contain full reset sequence")
// Print the result for manual inspection if needed
if t.Failed() {
fmt.Printf("Original: %q\nResult: %q\n", tc.content, result)
}
})
}
}
// TestApplyHighlightingWithMultipleSegments tests highlighting multiple segments
func TestApplyHighlightingWithMultipleSegments(t *testing.T) {
t.Parallel()
// Mock theme colors for testing
mockHighlightBg := lipgloss.AdaptiveColor{
Dark: "#FF0000", // Red background for highlighting
Light: "#FF0000",
}
content := "This is a test with multiple segments to highlight"
segments := []Segment{
{Start: 0, End: 4, Type: LineAdded}, // "This"
{Start: 8, End: 9, Type: LineAdded}, // "a"
{Start: 15, End: 23, Type: LineAdded}, // "multiple"
}
result := applyHighlighting(content, segments, LineAdded, mockHighlightBg)
// Verify the result contains the full reset sequence
assert.Contains(t, result, "\x1b[0m",
"Result should contain full reset sequence")
}
+252
View File
@@ -0,0 +1,252 @@
package history
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/pubsub"
)
const (
InitialVersion = "initial"
)
type File struct {
ID string
SessionID string
Path string
Content string
Version string
CreatedAt int64
UpdatedAt int64
}
type Service interface {
pubsub.Suscriber[File]
Create(ctx context.Context, sessionID, path, content string) (File, error)
CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
Get(ctx context.Context, id string) (File, error)
GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
ListBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
Update(ctx context.Context, file File) (File, error)
Delete(ctx context.Context, id string) error
DeleteSessionFiles(ctx context.Context, sessionID string) error
}
type service struct {
*pubsub.Broker[File]
db *sql.DB
q *db.Queries
}
func NewService(q *db.Queries, db *sql.DB) Service {
return &service{
Broker: pubsub.NewBroker[File](),
q: q,
db: db,
}
}
func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
return s.createWithVersion(ctx, sessionID, path, content, InitialVersion)
}
func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
// Get the latest version for this path
files, err := s.q.ListFilesByPath(ctx, path)
if err != nil {
return File{}, err
}
if len(files) == 0 {
// No previous versions, create initial
return s.Create(ctx, sessionID, path, content)
}
// Get the latest version
latestFile := files[0] // Files are ordered by created_at DESC
latestVersion := latestFile.Version
// Generate the next version
var nextVersion string
if latestVersion == InitialVersion {
nextVersion = "v1"
} else if strings.HasPrefix(latestVersion, "v") {
versionNum, err := strconv.Atoi(latestVersion[1:])
if err != nil {
// If we can't parse the version, just use a timestamp-based version
nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
} else {
nextVersion = fmt.Sprintf("v%d", versionNum+1)
}
} else {
// If the version format is unexpected, use a timestamp-based version
nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
}
return s.createWithVersion(ctx, sessionID, path, content, nextVersion)
}
func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string) (File, error) {
// Maximum number of retries for transaction conflicts
const maxRetries = 3
var file File
var err error
// Retry loop for transaction conflicts
for attempt := range maxRetries {
// Start a transaction
tx, txErr := s.db.Begin()
if txErr != nil {
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
}
// Create a new queries instance with the transaction
qtx := s.q.WithTx(tx)
// Try to create the file within the transaction
dbFile, txErr := qtx.CreateFile(ctx, db.CreateFileParams{
ID: uuid.New().String(),
SessionID: sessionID,
Path: path,
Content: content,
Version: version,
})
if txErr != nil {
// Rollback the transaction
tx.Rollback()
// Check if this is a uniqueness constraint violation
if strings.Contains(txErr.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{}, txErr
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
return File{}, fmt.Errorf("failed to commit transaction: %w", txErr)
}
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) {
dbFile, err := s.q.GetFile(ctx, id)
if err != nil {
return File{}, err
}
return s.fromDBItem(dbFile), nil
}
func (s *service) GetByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
dbFile, err := s.q.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
Path: path,
SessionID: sessionID,
})
if err != nil {
return File{}, err
}
return s.fromDBItem(dbFile), nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
dbFiles, err := s.q.ListFilesBySession(ctx, sessionID)
if err != nil {
return nil, err
}
files := make([]File, len(dbFiles))
for i, dbFile := range dbFiles {
files[i] = s.fromDBItem(dbFile)
}
return files, nil
}
func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
dbFiles, err := s.q.ListLatestSessionFiles(ctx, sessionID)
if err != nil {
return nil, err
}
files := make([]File, len(dbFiles))
for i, dbFile := range dbFiles {
files[i] = s.fromDBItem(dbFile)
}
return files, nil
}
func (s *service) Update(ctx context.Context, file File) (File, error) {
dbFile, err := s.q.UpdateFile(ctx, db.UpdateFileParams{
ID: file.ID,
Content: file.Content,
Version: file.Version,
})
if err != nil {
return File{}, err
}
updatedFile := s.fromDBItem(dbFile)
s.Publish(pubsub.UpdatedEvent, updatedFile)
return updatedFile, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
file, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteFile(ctx, id)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, file)
return nil
}
func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
files, err := s.ListBySession(ctx, sessionID)
if err != nil {
return err
}
for _, file := range files {
err = s.Delete(ctx, file.ID)
if err != nil {
return err
}
}
return nil
}
func (s *service) fromDBItem(item db.File) File {
return File{
ID: item.ID,
SessionID: item.SessionID,
Path: item.Path,
Content: item.Content,
Version: item.Version,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
-441
View File
@@ -1,441 +0,0 @@
package history
import (
"context"
"database/sql"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
)
const (
InitialVersion = "initial"
)
type File struct {
ID string
SessionID string
Path string
Content string
Version string
CreatedAt time.Time
UpdatedAt time.Time
}
const (
EventFileCreated pubsub.EventType = "history_file_created"
EventFileVersionCreated pubsub.EventType = "history_file_version_created"
EventFileUpdated pubsub.EventType = "history_file_updated"
EventFileDeleted pubsub.EventType = "history_file_deleted"
EventSessionFilesDeleted pubsub.EventType = "history_session_files_deleted"
)
type Service interface {
pubsub.Subscriber[File]
Create(ctx context.Context, sessionID, path, content string) (File, error)
CreateVersion(ctx context.Context, sessionID, path, content string) (File, error)
Get(ctx context.Context, id string) (File, error)
GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error)
GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error)
ListBySession(ctx context.Context, sessionID string) ([]File, error)
ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error)
ListVersionsByPath(ctx context.Context, path string) ([]File, error)
Update(ctx context.Context, file File) (File, error)
Delete(ctx context.Context, id string) error
DeleteSessionFiles(ctx context.Context, sessionID string) error
}
type service struct {
db *db.Queries
sqlDB *sql.DB
broker *pubsub.Broker[File]
mu sync.RWMutex
}
var globalHistoryService *service
func InitService(sqlDatabase *sql.DB) error {
if globalHistoryService != nil {
return fmt.Errorf("history service already initialized")
}
queries := db.New(sqlDatabase)
broker := pubsub.NewBroker[File]()
globalHistoryService = &service{
db: queries,
sqlDB: sqlDatabase,
broker: broker,
}
return nil
}
func GetService() Service {
if globalHistoryService == nil {
panic("history service not initialized. Call history.InitService() first.")
}
return globalHistoryService
}
func (s *service) Create(ctx context.Context, sessionID, path, content string) (File, error) {
return s.createWithVersion(ctx, sessionID, path, content, InitialVersion, EventFileCreated)
}
func (s *service) CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
s.mu.RLock()
files, err := s.db.ListFilesByPath(ctx, path)
s.mu.RUnlock()
if err != nil && err != sql.ErrNoRows {
return File{}, fmt.Errorf("db.ListFilesByPath for next version: %w", err)
}
latestVersionNumber := 0
if len(files) > 0 {
// Sort to be absolutely sure about the latest version globally for this path
slices.SortFunc(files, func(a, b db.File) int {
if strings.HasPrefix(a.Version, "v") && strings.HasPrefix(b.Version, "v") {
vA, _ := strconv.Atoi(a.Version[1:])
vB, _ := strconv.Atoi(b.Version[1:])
return vB - vA // Descending to get latest first
}
if a.Version == InitialVersion && b.Version != InitialVersion {
return 1 // initial comes after vX
}
if b.Version == InitialVersion && a.Version != InitialVersion {
return -1
}
// Compare timestamps as strings (ISO format sorts correctly)
if b.CreatedAt > a.CreatedAt {
return 1
} else if a.CreatedAt > b.CreatedAt {
return -1
}
return 0 // Equal timestamps
})
latestFile := files[0]
if strings.HasPrefix(latestFile.Version, "v") {
vNum, parseErr := strconv.Atoi(latestFile.Version[1:])
if parseErr == nil {
latestVersionNumber = vNum
}
}
}
nextVersionStr := fmt.Sprintf("v%d", latestVersionNumber+1)
return s.createWithVersion(ctx, sessionID, path, content, nextVersionStr, EventFileVersionCreated)
}
func (s *service) createWithVersion(ctx context.Context, sessionID, path, content, version string, eventType pubsub.EventType) (File, error) {
s.mu.Lock()
defer s.mu.Unlock()
const maxRetries = 3
var file File
var err error
for attempt := range maxRetries {
tx, txErr := s.sqlDB.BeginTx(ctx, nil)
if txErr != nil {
return File{}, fmt.Errorf("failed to begin transaction: %w", txErr)
}
qtx := s.db.WithTx(tx)
dbFile, createErr := qtx.CreateFile(ctx, db.CreateFileParams{
ID: uuid.New().String(),
SessionID: sessionID,
Path: path,
Content: content,
Version: version,
})
if createErr != nil {
if rbErr := tx.Rollback(); rbErr != nil {
slog.Error("Failed to rollback transaction on create error", "error", rbErr)
}
if strings.Contains(createErr.Error(), "UNIQUE constraint failed: files.path, files.session_id, files.version") {
if attempt < maxRetries-1 {
slog.Warn("Unique constraint violation for file version, retrying with incremented version", "path", path, "session", sessionID, "attempted_version", version, "attempt", attempt+1)
// Increment version string like v1, v2, v3...
if strings.HasPrefix(version, "v") {
numPart := version[1:]
num, parseErr := strconv.Atoi(numPart)
if parseErr == nil {
version = fmt.Sprintf("v%d", num+1)
continue // Retry with new version
}
}
// Fallback if version is not "vX" or parsing failed
version = fmt.Sprintf("%s-retry%d", version, attempt+1)
continue
}
}
return File{}, fmt.Errorf("db.CreateFile within transaction: %w", createErr)
}
if commitErr := tx.Commit(); commitErr != nil {
return File{}, fmt.Errorf("failed to commit transaction: %w", commitErr)
}
file = s.fromDBItem(dbFile)
s.broker.Publish(eventType, file)
return file, nil // Success
}
return File{}, fmt.Errorf("failed to create file after %d retries due to version conflicts: %w", maxRetries, err)
}
func (s *service) Get(ctx context.Context, id string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFile, err := s.db.GetFile(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return File{}, fmt.Errorf("file with ID '%s' not found", id)
}
return File{}, fmt.Errorf("db.GetFile: %w", err)
}
return s.fromDBItem(dbFile), nil
}
func (s *service) GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// sqlc doesn't directly support GetyByPathAndVersionAndSession
// We list and filter. This could be optimized with a custom query if performance is an issue.
allFilesForPath, err := s.db.ListFilesByPath(ctx, path)
if err != nil {
return File{}, fmt.Errorf("db.ListFilesByPath for GetByPathAndVersion: %w", err)
}
for _, dbFile := range allFilesForPath {
if dbFile.SessionID == sessionID && dbFile.Version == version {
return s.fromDBItem(dbFile), nil
}
}
return File{}, fmt.Errorf("file not found for session '%s', path '%s', version '%s'", sessionID, path, version)
}
func (s *service) GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// GetFileByPathAndSession in sqlc already orders by created_at DESC and takes LIMIT 1
dbFile, err := s.db.GetFileByPathAndSession(ctx, db.GetFileByPathAndSessionParams{
Path: path,
SessionID: sessionID,
})
if err != nil {
if err == sql.ErrNoRows {
return File{}, fmt.Errorf("no file found for path '%s' in session '%s'", path, sessionID)
}
return File{}, fmt.Errorf("db.GetFileByPathAndSession: %w", err)
}
return s.fromDBItem(dbFile), nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListFilesBySession(ctx, sessionID) // Assumes this orders by created_at ASC
if err != nil {
return nil, fmt.Errorf("db.ListFilesBySession: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListLatestSessionFiles(ctx, sessionID) // Uses the specific sqlc query
if err != nil {
return nil, fmt.Errorf("db.ListLatestSessionFiles: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbFiles, err := s.db.ListFilesByPath(ctx, path) // sqlc query orders by created_at DESC
if err != nil {
return nil, fmt.Errorf("db.ListFilesByPath: %w", err)
}
files := make([]File, len(dbFiles))
for i, dbF := range dbFiles {
files[i] = s.fromDBItem(dbF)
}
return files, nil
}
func (s *service) Update(ctx context.Context, file File) (File, error) {
s.mu.Lock()
defer s.mu.Unlock()
if file.ID == "" {
return File{}, fmt.Errorf("cannot update file with empty ID")
}
// UpdatedAt is handled by DB trigger
dbFile, err := s.db.UpdateFile(ctx, db.UpdateFileParams{
ID: file.ID,
Content: file.Content,
Version: file.Version,
})
if err != nil {
return File{}, fmt.Errorf("db.UpdateFile: %w", err)
}
updatedFile := s.fromDBItem(dbFile)
s.broker.Publish(EventFileUpdated, updatedFile)
return updatedFile, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
fileToPublish, err := s.getServiceForPublish(ctx, id) // Use internal method with appropriate locking
s.mu.Unlock()
if err != nil {
if strings.Contains(err.Error(), "not found") {
slog.Warn("Attempted to delete non-existent file history", "id", id)
return nil // Or return specific error if needed
}
return err
}
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteFile(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteFile: %w", err)
}
if fileToPublish != nil {
s.broker.Publish(EventFileDeleted, *fileToPublish)
}
return nil
}
func (s *service) getServiceForPublish(ctx context.Context, id string) (*File, error) {
// Assumes outer lock is NOT held or caller manages it.
// For GetFile, it has its own RLock.
dbFile, err := s.db.GetFile(ctx, id)
if err != nil {
return nil, err
}
file := s.fromDBItem(dbFile)
return &file, nil
}
func (s *service) DeleteSessionFiles(ctx context.Context, sessionID string) error {
s.mu.Lock() // Lock for the entire operation
defer s.mu.Unlock()
// Get files first for publishing events
filesToDelete, err := s.db.ListFilesBySession(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.ListFilesBySession for deletion: %w", err)
}
err = s.db.DeleteSessionFiles(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.DeleteSessionFiles: %w", err)
}
for _, dbFile := range filesToDelete {
file := s.fromDBItem(dbFile)
s.broker.Publish(EventFileDeleted, file) // Individual delete events
}
return nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.File) File {
// Parse timestamps from ISO strings
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
createdAt = time.Now() // Fallback
}
updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
updatedAt = time.Now() // Fallback
}
return File{
ID: item.ID,
SessionID: item.SessionID,
Path: item.Path,
Content: item.Content,
Version: item.Version,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
func Create(ctx context.Context, sessionID, path, content string) (File, error) {
return GetService().Create(ctx, sessionID, path, content)
}
func CreateVersion(ctx context.Context, sessionID, path, content string) (File, error) {
return GetService().CreateVersion(ctx, sessionID, path, content)
}
func Get(ctx context.Context, id string) (File, error) {
return GetService().Get(ctx, id)
}
func GetByPathAndVersion(ctx context.Context, sessionID, path, version string) (File, error) {
return GetService().GetByPathAndVersion(ctx, sessionID, path, version)
}
func GetLatestByPathAndSession(ctx context.Context, path, sessionID string) (File, error) {
return GetService().GetLatestByPathAndSession(ctx, path, sessionID)
}
func ListBySession(ctx context.Context, sessionID string) ([]File, error) {
return GetService().ListBySession(ctx, sessionID)
}
func ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) {
return GetService().ListLatestSessionFiles(ctx, sessionID)
}
func ListVersionsByPath(ctx context.Context, path string) ([]File, error) {
return GetService().ListVersionsByPath(ctx, path)
}
func Update(ctx context.Context, file File) (File, error) {
return GetService().Update(ctx, file)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func DeleteSessionFiles(ctx context.Context, sessionID string) error {
return GetService().DeleteSessionFiles(ctx, sessionID)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[File] {
return GetService().Subscribe(ctx)
}
+8 -6
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
)
type agentTool struct {
@@ -88,8 +88,10 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
}
parentSession.Cost += updatedSession.Cost
parentSession.PromptTokens += updatedSession.PromptTokens
parentSession.CompletionTokens += updatedSession.CompletionTokens
_, err = b.sessions.Update(ctx, parentSession)
_, err = b.sessions.Save(ctx, parentSession)
if err != nil {
return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err)
}
+69 -367
View File
@@ -6,20 +6,16 @@ import (
"fmt"
"strings"
"sync"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/prompt"
"github.com/sst/opencode/internal/llm/provider"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/prompt"
"github.com/opencode-ai/opencode/internal/llm/provider"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/session"
)
// Common errors
@@ -47,9 +43,6 @@ 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, force bool) error
GetUsage(ctx context.Context, sessionID string) (*int64, error)
EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error)
}
type agent struct {
@@ -75,8 +68,8 @@ func NewAgent(
return nil, err
}
var titleProvider provider.Provider
// Only generate titles for the primary agent
if agentName == config.AgentPrimary {
// Only generate titles for the coder agent
if agentName == config.AgentCoder {
titleProvider, err = createAgentProvider(config.AgentTitle)
if err != nil {
return nil, err
@@ -98,7 +91,7 @@ func NewAgent(
func (a *agent) Cancel(sessionID string) {
if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
if cancel, ok := cancelFunc.(context.CancelFunc); ok {
status.Info(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
logging.InfoPersist(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
cancel()
}
}
@@ -155,7 +148,7 @@ func (a *agent) generateTitle(ctx context.Context, sessionID string, content str
}
session.Title = title
_, err = a.sessions.Update(ctx, session)
_, err = a.sessions.Save(ctx, session)
return err
}
@@ -178,7 +171,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
a.activeRequests.Store(sessionID, cancel)
go func() {
slog.Debug("Request started", "sessionID", sessionID)
logging.Debug("Request started", "sessionID", sessionID)
defer logging.RecoverPanic("agent.Run", func() {
events <- a.err(fmt.Errorf("panic while running the agent"))
})
@@ -188,87 +181,41 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
}
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
if result.Err() != nil && !errors.Is(result.Err(), ErrRequestCancelled) && !errors.Is(result.Err(), context.Canceled) {
status.Error(result.Err().Error())
logging.ErrorPersist(result.Err().Error())
}
slog.Debug("Request completed", "sessionID", sessionID)
logging.Debug("Request completed", "sessionID", sessionID)
a.activeRequests.Delete(sessionID)
cancel()
events <- result
close(events)
}()
return events, nil
}
func (a *agent) prepareMessageHistory(ctx context.Context, sessionID string) (session.Session, []message.Message, error) {
currentSession, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return currentSession, nil, fmt.Errorf("failed to get session: %w", err)
}
var sessionMessages []message.Message
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
// If summary exists, only fetch messages after the summarization timestamp
sessionMessages, err = a.messages.ListAfter(ctx, sessionID, currentSession.SummarizedAt)
if err != nil {
return currentSession, nil, 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 currentSession, nil, fmt.Errorf("failed to list messages: %w", err)
}
}
var messages []message.Message
if currentSession.Summary != "" && !currentSession.SummarizedAt.IsZero() {
// 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
}
return currentSession, messages, nil
}
func (a *agent) triggerTitleGeneration(sessionID string, content string) {
go func() {
defer logging.RecoverPanic("agent.Run", func() {
status.Error("panic while generating title")
})
titleErr := a.generateTitle(context.Background(), sessionID, content)
if titleErr != nil {
status.Error(fmt.Sprintf("failed to generate title: %v", titleErr))
}
}()
}
func (a *agent) processGeneration(ctx context.Context, sessionID, content string, attachmentParts []message.ContentPart) AgentEvent {
currentSession, sessionMessages, err := a.prepareMessageHistory(ctx, sessionID)
// List existing messages; if none, start title generation asynchronously.
msgs, err := a.messages.List(ctx, sessionID)
if err != nil {
return a.err(err)
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 == "" {
a.triggerTitleGeneration(sessionID, content)
if len(msgs) == 0 {
go func() {
defer logging.RecoverPanic("agent.Run", func() {
logging.ErrorPersist("panic while generating title")
})
titleErr := a.generateTitle(context.Background(), sessionID, content)
if titleErr != nil {
logging.ErrorPersist(fmt.Sprintf("failed to generate title: %v", titleErr))
}
}()
}
userMsg, err := a.createUserMessage(ctx, sessionID, content, attachmentParts)
if err != nil {
return a.err(fmt.Errorf("failed to create user message: %w", err))
}
messages := append(sessionMessages, userMsg)
// Append the new user message to the conversation history.
msgHistory := append(msgs, userMsg)
for {
// Check for cancellation before each iteration
@@ -278,42 +225,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
default:
// Continue processing
}
// Check if auto-compaction is needed before calling the provider
usagePercentage, needsCompaction, errEstimate := a.EstimateContextWindowUsage(ctx, sessionID)
if errEstimate != nil {
slog.Warn("Failed to estimate context window usage for auto-compaction", "error", errEstimate, "sessionID", sessionID)
} else if needsCompaction {
status.Info(fmt.Sprintf("Context window usage is at %.2f%%. Auto-compacting conversation...", usagePercentage))
// Run compaction synchronously
compactCtx, cancelCompact := context.WithTimeout(ctx, 30*time.Second) // Use appropriate context
errCompact := a.CompactSession(compactCtx, sessionID, true)
cancelCompact()
if errCompact != nil {
status.Warn(fmt.Sprintf("Auto-compaction failed: %v. Context window usage may continue to grow.", errCompact))
} else {
status.Info("Auto-compaction completed successfully.")
// After compaction, message history needs to be re-prepared.
// The 'messages' slice needs to be updated with the new summary and subsequent messages,
// ensuring the latest user message is correctly appended.
_, sessionMessagesFromCompact, errPrepare := a.prepareMessageHistory(ctx, sessionID)
if errPrepare != nil {
return a.err(fmt.Errorf("failed to re-prepare message history after compaction: %w", errPrepare))
}
messages = sessionMessagesFromCompact
// Ensure the user message that triggered this cycle is the last one.
// 'userMsg' was created before this loop using a.createUserMessage.
// It should be appended to the 'messages' slice if it's not already the last element.
if len(messages) == 0 || (len(messages) > 0 && messages[len(messages)-1].ID != userMsg.ID) {
messages = append(messages, userMsg)
}
}
}
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, messages)
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
if err != nil {
if errors.Is(err, context.Canceled) {
agentMessage.AddFinish(message.FinishReasonCanceled)
@@ -322,10 +234,10 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
}
return a.err(fmt.Errorf("failed to process events: %w", err))
}
slog.Info("Result", "message", agentMessage.FinishReason(), "toolResults", toolResults)
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
messages = append(messages, agentMessage, *toolResults)
msgHistory = append(msgHistory, agentMessage, *toolResults)
continue
}
return AgentEvent{
@@ -343,27 +255,6 @@ func (a *agent) createUserMessage(ctx context.Context, sessionID, content string
})
}
func (a *agent) createToolResponseMessage(ctx context.Context, sessionID string, toolResults []message.ToolResult) (*message.Message, error) {
if len(toolResults) == 0 {
return nil, nil
}
parts := make([]message.ContentPart, 0, len(toolResults))
for _, tr := range toolResults {
parts = append(parts, tr)
}
msg, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: parts,
})
if err != nil {
return nil, fmt.Errorf("failed to create tool response message: %w", err)
}
return &msg, nil
}
func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msgHistory []message.Message) (message.Message, *message.Message, error) {
eventChan := a.provider.StreamResponse(ctx, msgHistory, a.tools)
@@ -392,37 +283,12 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
}
}
// If the assistant wants to use tools, execute them
if assistantMsg.FinishReason() == message.FinishReasonToolUse {
toolCalls := assistantMsg.ToolCalls()
if len(toolCalls) > 0 {
toolResults, err := a.executeToolCalls(ctx, toolCalls)
if err != nil {
if errors.Is(err, context.Canceled) {
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
}
return assistantMsg, nil, err
}
// Create a message with the tool results
toolResponseMsg, err := a.createToolResponseMessage(ctx, sessionID, toolResults)
if err != nil {
return assistantMsg, nil, err
}
return assistantMsg, toolResponseMsg, nil
}
}
return assistantMsg, nil, nil
}
func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCall) ([]message.ToolResult, error) {
toolResults := make([]message.ToolResult, len(toolCalls))
toolResults := make([]message.ToolResult, len(assistantMsg.ToolCalls()))
toolCalls := assistantMsg.ToolCalls()
for i, toolCall := range toolCalls {
select {
case <-ctx.Done():
a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
// Make all future tool calls cancelled
for j := i; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
@@ -431,7 +297,7 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
IsError: true,
}
}
return toolResults, ctx.Err()
goto out
default:
// Continue processing
var tool tools.BaseTool
@@ -450,13 +316,11 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
}
continue
}
toolResult, toolErr := tool.Run(ctx, tools.ToolCall{
ID: toolCall.ID,
Name: toolCall.Name,
Input: toolCall.Input,
})
if toolErr != nil {
if errors.Is(toolErr, permission.ErrorPermissionDenied) {
toolResults[i] = message.ToolResult{
@@ -464,7 +328,6 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
Content: "Permission denied",
IsError: true,
}
// Cancel all remaining tool calls if permission is denied
for j := i + 1; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
ToolCallID: toolCalls[j].ID,
@@ -472,18 +335,10 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
IsError: true,
}
}
return toolResults, nil
a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
break
}
// Handle other errors
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: toolErr.Error(),
IsError: true,
}
continue
}
toolResults[i] = message.ToolResult{
ToolCallID: toolCall.ID,
Content: toolResult.Content,
@@ -492,13 +347,28 @@ func (a *agent) executeToolCalls(ctx context.Context, toolCalls []message.ToolCa
}
}
}
out:
if len(toolResults) == 0 {
return assistantMsg, nil, nil
}
parts := make([]message.ContentPart, 0)
for _, tr := range toolResults {
parts = append(parts, tr)
}
msg, err := a.messages.Create(context.Background(), assistantMsg.SessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: parts,
})
if err != nil {
return assistantMsg, nil, fmt.Errorf("failed to create cancelled tool message: %w", err)
}
return toolResults, nil
return assistantMsg, &msg, err
}
func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
msg.AddFinish(finishReson)
_, _ = a.messages.Update(ctx, *msg)
_ = a.messages.Update(ctx, *msg)
}
func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg *message.Message, event provider.ProviderEvent) error {
@@ -506,22 +376,19 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
case <-ctx.Done():
return ctx.Err()
default:
// Continue processing
// Continue processing.
}
switch event.Type {
case provider.EventThinkingDelta:
assistantMsg.AppendReasoningContent(event.Content)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventContentDelta:
assistantMsg.AppendContent(event.Content)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventToolUseStart:
assistantMsg.AddToolCall(*event.ToolCall)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
// TODO: see how to handle this
// case provider.EventToolUseDelta:
// tm := time.Unix(assistantMsg.UpdatedAt, 0)
@@ -533,19 +400,18 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
// }
case provider.EventToolUseStop:
assistantMsg.FinishToolCall(event.ToolCall.ID)
_, err := a.messages.Update(ctx, *assistantMsg)
return err
return a.messages.Update(ctx, *assistantMsg)
case provider.EventError:
if errors.Is(event.Error, context.Canceled) {
status.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
logging.InfoPersist(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
return context.Canceled
}
status.Error(event.Error.Error())
logging.ErrorPersist(event.Error.Error())
return event.Error
case provider.EventComplete:
assistantMsg.SetToolCalls(event.Response.ToolCalls)
assistantMsg.AddFinish(event.Response.FinishReason)
if _, err := a.messages.Update(ctx, *assistantMsg); err != nil {
if err := a.messages.Update(ctx, *assistantMsg); err != nil {
return fmt.Errorf("failed to update message: %w", err)
}
return a.TrackUsage(ctx, sessionID, a.provider.Model(), event.Response.Usage)
@@ -554,49 +420,6 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
return nil
}
func (a *agent) GetUsage(ctx context.Context, sessionID string) (*int64, error) {
session, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
usage := session.PromptTokens + session.CompletionTokens
return &usage, nil
}
func (a *agent) EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error) {
session, err := a.sessions.Get(ctx, sessionID)
if err != nil {
return 0, false, fmt.Errorf("failed to get session: %w", err)
}
// Get the model's context window size
model := a.provider.Model()
contextWindow := model.ContextWindow
if contextWindow <= 0 {
// Default to a reasonable size if not specified
contextWindow = 100000
}
// Calculate current token usage
currentTokens := session.PromptTokens + session.CompletionTokens
// Get the max tokens setting for the agent
maxTokens := a.provider.MaxTokens()
// Calculate percentage of context window used
usagePercentage := float64(currentTokens) / float64(contextWindow)
// Check if we need to auto-compact
// Auto-compact when:
// 1. Usage exceeds 90% of context window, OR
// 2. Current usage + maxTokens would exceed 100% of context window
needsCompaction := usagePercentage >= 0.9 ||
float64(currentTokens+maxTokens) > float64(contextWindow)
return usagePercentage * 100, needsCompaction, nil
}
func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error {
sess, err := a.sessions.Get(ctx, sessionID)
if err != nil {
@@ -609,10 +432,10 @@ func (a *agent) TrackUsage(ctx context.Context, sessionID string, model models.M
model.CostPer1MOut/1e6*float64(usage.OutputTokens)
sess.Cost += cost
sess.CompletionTokens = usage.OutputTokens + usage.CacheReadTokens
sess.PromptTokens = usage.InputTokens + usage.CacheCreationTokens
sess.CompletionTokens += usage.OutputTokens
sess.PromptTokens += usage.InputTokens
_, err = a.sessions.Update(ctx, sess)
_, err = a.sessions.Save(ctx, sess)
if err != nil {
return fmt.Errorf("failed to save session: %w", err)
}
@@ -638,127 +461,6 @@ func (a *agent) Update(agentName config.AgentName, modelID models.ModelID) (mode
return a.provider.Model(), nil
}
func (a *agent) CompactSession(ctx context.Context, sessionID string, force bool) error {
// Check if the session is busy
if a.IsSessionBusy(sessionID) && !force {
return ErrSessionBusy
}
// 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.IsZero() {
// Filter messages that were created after the last summarization
var newMessages []message.Message
for _, msg := range sessionMessages {
if msg.CreatedAt.After(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.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.`,
},
},
},
}
// If there's an existing summary, include it
if existingSummary != "" {
messages = append(messages, message.Message{
Role: message.Assistant,
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
session.Summary = summaryText
session.SummarizedAt = time.Now()
// Save the updated session
_, err = a.sessions.Update(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]
@@ -794,7 +496,7 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
provider.WithReasoningEffort(agentConfig.ReasoningEffort),
),
)
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentPrimary {
} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
opts = append(
opts,
provider.WithAnthropicOptions(
+9 -10
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/version"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/version"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/mcp"
@@ -86,7 +86,6 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes
}
permissionDescription := fmt.Sprintf("execute %s with the following parameters: %s", b.Info().Name, params.Input)
p := b.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
@@ -147,13 +146,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
_, err := c.Initialize(ctx, initRequest)
if err != nil {
slog.Error("error initializing mcp client", "error", err)
logging.Error("error initializing mcp client", "error", err)
return stdioTools
}
toolsRequest := mcp.ListToolsRequest{}
tools, err := c.ListTools(ctx, toolsRequest)
if err != nil {
slog.Error("error listing tools", "error", err)
logging.Error("error listing tools", "error", err)
return stdioTools
}
for _, t := range tools.Tools {
@@ -176,7 +175,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
m.Args...,
)
if err != nil {
slog.Error("error creating mcp client", "error", err)
logging.Error("error creating mcp client", "error", err)
continue
}
@@ -187,7 +186,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
client.WithHeaders(m.Headers),
)
if err != nil {
slog.Error("error creating mcp client", "error", err)
logging.Error("error creating mcp client", "error", err)
continue
}
mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
+14 -20
View File
@@ -3,15 +3,15 @@ package agent
import (
"context"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/session"
)
func PrimaryAgentTools(
func CoderAgentTools(
permissions permission.Service,
sessions session.Service,
messages message.Service,
@@ -19,8 +19,10 @@ func PrimaryAgentTools(
lspClients map[string]*lsp.Client,
) []tools.BaseTool {
ctx := context.Background()
mcpTools := GetMcpTools(ctx, permissions)
otherTools := GetMcpTools(ctx, permissions)
if len(lspClients) > 0 {
otherTools = append(otherTools, tools.NewDiagnosticsTool(lspClients))
}
return append(
[]tools.BaseTool{
tools.NewBashTool(permissions),
@@ -29,17 +31,12 @@ func PrimaryAgentTools(
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients),
tools.NewPatchTool(lspClients, permissions, history),
tools.NewWriteTool(lspClients, permissions, history),
tools.NewDiagnosticsTool(lspClients),
tools.NewDefinitionTool(lspClients),
tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients),
tools.NewCodeActionTool(lspClients),
NewAgentTool(sessions, messages, lspClients),
}, mcpTools...,
}, otherTools...,
)
}
@@ -48,10 +45,7 @@ func TaskAgentTools(lspClients map[string]*lsp.Client) []tools.BaseTool {
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewSourcegraphTool(),
tools.NewViewTool(lspClients),
tools.NewDefinitionTool(lspClients),
tools.NewReferencesTool(lspClients),
tools.NewDocSymbolsTool(lspClients),
tools.NewWorkspaceSymbolsTool(lspClients),
}
}
+8 -12
View File
@@ -72,18 +72,14 @@ var SupportedModels = map[ModelID]Model{
//
// // Bedrock
BedrockClaude37Sonnet: {
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
ContextWindow: 200_000,
DefaultMaxTokens: 50_000,
CanReason: true,
SupportsAttachments: true,
ID: BedrockClaude37Sonnet,
Name: "Bedrock: Claude 3.7 Sonnet",
Provider: ProviderBedrock,
APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0",
CostPer1MIn: 3.0,
CostPer1MInCached: 3.75,
CostPer1MOutCached: 0.30,
CostPer1MOut: 15.0,
},
}
+19 -84
View File
@@ -22,17 +22,12 @@ const (
OpenRouterClaude37Sonnet ModelID = "openrouter.claude-3.7-sonnet"
OpenRouterClaude35Haiku ModelID = "openrouter.claude-3.5-haiku"
OpenRouterClaude3Opus ModelID = "openrouter.claude-3-opus"
OpenRouterQwen235B ModelID = "openrouter.qwen-3-235b"
OpenRouterQwen32B ModelID = "openrouter.qwen-3-32b"
OpenRouterQwen30B ModelID = "openrouter.qwen-3-30b"
OpenRouterQwen14B ModelID = "openrouter.qwen-3-14b"
OpenRouterQwen8B ModelID = "openrouter.qwen-3-8b"
)
var OpenRouterModels = map[ModelID]Model{
OpenRouterGPT41: {
ID: OpenRouterGPT41,
Name: "OpenRouter: GPT 4.1",
Name: "OpenRouter GPT 4.1",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1",
CostPer1MIn: OpenAIModels[GPT41].CostPer1MIn,
@@ -44,7 +39,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGPT41Mini: {
ID: OpenRouterGPT41Mini,
Name: "OpenRouter: GPT 4.1 mini",
Name: "OpenRouter GPT 4.1 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1-mini",
CostPer1MIn: OpenAIModels[GPT41Mini].CostPer1MIn,
@@ -56,7 +51,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGPT41Nano: {
ID: OpenRouterGPT41Nano,
Name: "OpenRouter: GPT 4.1 nano",
Name: "OpenRouter GPT 4.1 nano",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.1-nano",
CostPer1MIn: OpenAIModels[GPT41Nano].CostPer1MIn,
@@ -68,7 +63,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGPT45Preview: {
ID: OpenRouterGPT45Preview,
Name: "OpenRouter: GPT 4.5 preview",
Name: "OpenRouter GPT 4.5 preview",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4.5-preview",
CostPer1MIn: OpenAIModels[GPT45Preview].CostPer1MIn,
@@ -80,7 +75,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGPT4o: {
ID: OpenRouterGPT4o,
Name: "OpenRouter: GPT 4o",
Name: "OpenRouter GPT 4o",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4o",
CostPer1MIn: OpenAIModels[GPT4o].CostPer1MIn,
@@ -92,7 +87,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGPT4oMini: {
ID: OpenRouterGPT4oMini,
Name: "OpenRouter: GPT 4o mini",
Name: "OpenRouter GPT 4o mini",
Provider: ProviderOpenRouter,
APIModel: "openai/gpt-4o-mini",
CostPer1MIn: OpenAIModels[GPT4oMini].CostPer1MIn,
@@ -103,7 +98,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO1: {
ID: OpenRouterO1,
Name: "OpenRouter: O1",
Name: "OpenRouter O1",
Provider: ProviderOpenRouter,
APIModel: "openai/o1",
CostPer1MIn: OpenAIModels[O1].CostPer1MIn,
@@ -116,7 +111,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO1Pro: {
ID: OpenRouterO1Pro,
Name: "OpenRouter: o1 pro",
Name: "OpenRouter o1 pro",
Provider: ProviderOpenRouter,
APIModel: "openai/o1-pro",
CostPer1MIn: OpenAIModels[O1Pro].CostPer1MIn,
@@ -129,7 +124,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO1Mini: {
ID: OpenRouterO1Mini,
Name: "OpenRouter: o1 mini",
Name: "OpenRouter o1 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o1-mini",
CostPer1MIn: OpenAIModels[O1Mini].CostPer1MIn,
@@ -142,7 +137,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO3: {
ID: OpenRouterO3,
Name: "OpenRouter: o3",
Name: "OpenRouter o3",
Provider: ProviderOpenRouter,
APIModel: "openai/o3",
CostPer1MIn: OpenAIModels[O3].CostPer1MIn,
@@ -155,7 +150,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO3Mini: {
ID: OpenRouterO3Mini,
Name: "OpenRouter: o3 mini",
Name: "OpenRouter o3 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o3-mini-high",
CostPer1MIn: OpenAIModels[O3Mini].CostPer1MIn,
@@ -168,7 +163,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterO4Mini: {
ID: OpenRouterO4Mini,
Name: "OpenRouter: o4 mini",
Name: "OpenRouter o4 mini",
Provider: ProviderOpenRouter,
APIModel: "openai/o4-mini-high",
CostPer1MIn: OpenAIModels[O4Mini].CostPer1MIn,
@@ -181,7 +176,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGemini25Flash: {
ID: OpenRouterGemini25Flash,
Name: "OpenRouter: Gemini 2.5 Flash",
Name: "OpenRouter Gemini 2.5 Flash",
Provider: ProviderOpenRouter,
APIModel: "google/gemini-2.5-flash-preview:thinking",
CostPer1MIn: GeminiModels[Gemini25Flash].CostPer1MIn,
@@ -193,7 +188,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterGemini25: {
ID: OpenRouterGemini25,
Name: "OpenRouter: Gemini 2.5 Pro",
Name: "OpenRouter Gemini 2.5 Pro",
Provider: ProviderOpenRouter,
APIModel: "google/gemini-2.5-pro-preview-03-25",
CostPer1MIn: GeminiModels[Gemini25].CostPer1MIn,
@@ -205,7 +200,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterClaude35Sonnet: {
ID: OpenRouterClaude35Sonnet,
Name: "OpenRouter: Claude 3.5 Sonnet",
Name: "OpenRouter Claude 3.5 Sonnet",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.5-sonnet",
CostPer1MIn: AnthropicModels[Claude35Sonnet].CostPer1MIn,
@@ -217,7 +212,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterClaude3Haiku: {
ID: OpenRouterClaude3Haiku,
Name: "OpenRouter: Claude 3 Haiku",
Name: "OpenRouter Claude 3 Haiku",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3-haiku",
CostPer1MIn: AnthropicModels[Claude3Haiku].CostPer1MIn,
@@ -229,7 +224,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterClaude37Sonnet: {
ID: OpenRouterClaude37Sonnet,
Name: "OpenRouter: Claude 3.7 Sonnet",
Name: "OpenRouter Claude 3.7 Sonnet",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.7-sonnet",
CostPer1MIn: AnthropicModels[Claude37Sonnet].CostPer1MIn,
@@ -242,7 +237,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterClaude35Haiku: {
ID: OpenRouterClaude35Haiku,
Name: "OpenRouter: Claude 3.5 Haiku",
Name: "OpenRouter Claude 3.5 Haiku",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3.5-haiku",
CostPer1MIn: AnthropicModels[Claude35Haiku].CostPer1MIn,
@@ -254,7 +249,7 @@ var OpenRouterModels = map[ModelID]Model{
},
OpenRouterClaude3Opus: {
ID: OpenRouterClaude3Opus,
Name: "OpenRouter: Claude 3 Opus",
Name: "OpenRouter Claude 3 Opus",
Provider: ProviderOpenRouter,
APIModel: "anthropic/claude-3-opus",
CostPer1MIn: AnthropicModels[Claude3Opus].CostPer1MIn,
@@ -264,64 +259,4 @@ var OpenRouterModels = map[ModelID]Model{
ContextWindow: AnthropicModels[Claude3Opus].ContextWindow,
DefaultMaxTokens: AnthropicModels[Claude3Opus].DefaultMaxTokens,
},
OpenRouterQwen235B: {
ID: OpenRouterQwen235B,
Name: "OpenRouter: Qwen3 235B A22B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-235b-a22b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.1,
CostPer1MOutCached: 0.1,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen32B: {
ID: OpenRouterQwen32B,
Name: "OpenRouter: Qwen3 32B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-32b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.3,
CostPer1MOutCached: 0.3,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen30B: {
ID: OpenRouterQwen30B,
Name: "OpenRouter: Qwen3 30B A3B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-30b-a3b",
CostPer1MIn: 0.1,
CostPer1MInCached: 0.1,
CostPer1MOut: 0.3,
CostPer1MOutCached: 0.3,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen14B: {
ID: OpenRouterQwen14B,
Name: "OpenRouter: Qwen3 14B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-14b",
CostPer1MIn: 0.7,
CostPer1MInCached: 0.7,
CostPer1MOut: 0.24,
CostPer1MOutCached: 0.24,
ContextWindow: 40960,
DefaultMaxTokens: 4096,
},
OpenRouterQwen8B: {
ID: OpenRouterQwen8B,
Name: "OpenRouter: Qwen3 8B",
Provider: ProviderOpenRouter,
APIModel: "qwen/qwen3-8b",
CostPer1MIn: 0.35,
CostPer1MInCached: 0.35,
CostPer1MOut: 0.138,
CostPer1MOutCached: 0.138,
ContextWindow: 128000,
DefaultMaxTokens: 4096,
},
}
@@ -8,23 +8,23 @@ import (
"runtime"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
)
func PrimaryPrompt(provider models.ModelProvider) string {
basePrompt := baseAnthropicPrimaryPrompt
func CoderPrompt(provider models.ModelProvider) string {
basePrompt := baseAnthropicCoderPrompt
switch provider {
case models.ProviderOpenAI:
basePrompt = baseOpenAIPrimaryPrompt
basePrompt = baseOpenAICoderPrompt
}
envInfo := getEnvironmentInfo()
return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
}
const baseOpenAIPrimaryPrompt = `
const baseOpenAICoderPrompt = `
You are operating as and within the OpenCode CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
You can:
@@ -71,7 +71,7 @@ You MUST adhere to the following criteria when executing the task:
- Remember the user does not see the full output of tools
`
const baseAnthropicPrimaryPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
@@ -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 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.
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.
# 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).
@@ -103,7 +103,7 @@ assistant: 4
<example>
user: is 11 a prime number?
assistant: yes
assistant: true
</example>
<example>
+7 -7
View File
@@ -7,16 +7,16 @@ import (
"strings"
"sync"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/logging"
)
func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
basePrompt := ""
switch agentName {
case config.AgentPrimary:
basePrompt = PrimaryPrompt(provider)
case config.AgentCoder:
basePrompt = CoderPrompt(provider)
case config.AgentTitle:
basePrompt = TitlePrompt(provider)
case config.AgentTask:
@@ -25,10 +25,10 @@ func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) s
basePrompt = "You are a helpful assistant"
}
if agentName == config.AgentPrimary || agentName == config.AgentTask {
if agentName == config.AgentCoder || agentName == config.AgentTask {
// Add context from project-specific instruction files if they exist
contextContent := getContextFromPaths()
slog.Debug("Context content", "Context", contextContent)
logging.Debug("Context content", "Context", contextContent)
if contextContent != "" {
return fmt.Sprintf("%s\n\n# Project-Specific Context\n Make sure to follow the instructions in the context below\n%s", basePrompt, contextContent)
}
+2 -6
View File
@@ -2,12 +2,11 @@ package prompt
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/sst/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -15,11 +14,8 @@ import (
func TestGetContextFromPaths(t *testing.T) {
t.Parallel()
lvl := new(slog.LevelVar)
lvl.Set(slog.LevelDebug)
tmpDir := t.TempDir()
_, err := config.Load(tmpDir, false, lvl)
_, err := config.Load(tmpDir, false)
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
+1 -1
View File
@@ -3,7 +3,7 @@ package prompt
import (
"fmt"
"github.com/sst/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/models"
)
func TaskPrompt(_ models.ModelProvider) string {
+1 -1
View File
@@ -1,6 +1,6 @@
package prompt
import "github.com/sst/opencode/internal/llm/models"
import "github.com/opencode-ai/opencode/internal/llm/models"
func TitlePrompt(_ models.ModelProvider) string {
return `you will generate a short title based on the first message a user begins a conversation with
+19 -24
View File
@@ -12,12 +12,11 @@ import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/bedrock"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
)
type anthropicOptions struct {
@@ -83,18 +82,14 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
case message.Assistant:
blocks := []anthropic.ContentBlockParamUnion{}
if msg.Content() != nil {
content := msg.Content().String()
if strings.TrimSpace(content) != "" {
block := anthropic.NewTextBlock(content)
if cache && !a.options.disableCache {
block.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
}
if msg.Content().String() != "" {
content := anthropic.NewTextBlock(msg.Content().String())
if cache && !a.options.disableCache {
content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
}
blocks = append(blocks, block)
}
blocks = append(blocks, content)
}
for _, toolCall := range msg.ToolCalls() {
@@ -107,7 +102,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
}
if len(blocks) == 0 {
slog.Warn("There is a message without content, investigate, this should not happen")
logging.Warn("There is a message without content, investigate, this should not happen")
continue
}
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
@@ -210,7 +205,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(preparedMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
@@ -222,13 +217,13 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
slog.Error("Error in Anthropic API call", "error", err)
logging.Error("Error in Anthropic API call", "error", err)
retry, after, retryErr := a.shouldRetry(attempts, err)
if retryErr != nil {
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -258,8 +253,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)
slog.Debug("Prepared messages", "messages", string(jsonData))
// jsonData, _ := json.Marshal(preparedMessages)
// logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
eventChan := make(chan ProviderEvent)
@@ -277,7 +272,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
event := anthropicStream.Current()
err := accumulatedMessage.Accumulate(event)
if err != nil {
slog.Warn("Error accumulating message", "error", err)
logging.Warn("Error accumulating message", "error", err)
continue
}
@@ -366,7 +361,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
// context cancelled
+3 -2
View File
@@ -7,8 +7,8 @@ import (
"os"
"strings"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/message"
)
type bedrockOptions struct {
@@ -98,3 +98,4 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
return b.childProvider.stream(ctx, messages, tools)
}
+9 -10
View File
@@ -10,12 +10,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"google.golang.org/genai"
"log/slog"
)
type geminiOptions struct {
@@ -40,7 +39,7 @@ func newGeminiClient(opts providerClientOptions) GeminiClient {
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{APIKey: opts.apiKey, Backend: genai.BackendGeminiAPI})
if err != nil {
slog.Error("Failed to create Gemini client", "error", err)
logging.Error("Failed to create Gemini client", "error", err)
return nil
}
@@ -171,7 +170,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(geminiMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
@@ -201,7 +200,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -256,7 +255,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(geminiMessages)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
history := geminiMessages[:len(geminiMessages)-1] // All but last message
@@ -297,7 +296,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
if ctx.Err() != nil {
+10 -19
View File
@@ -11,12 +11,11 @@ import (
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
)
type openaiOptions struct {
@@ -183,14 +182,6 @@ func (o *openaiClient) preparedParams(messages []openai.ChatCompletionMessagePar
params.MaxTokens = openai.Int(o.providerOptions.maxTokens)
}
if o.providerOptions.model.Provider == models.ProviderOpenRouter {
params.WithExtraFields(map[string]any{
"provider": map[string]any{
"require_parameters": true,
},
})
}
return params
}
@@ -199,7 +190,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
for {
@@ -215,7 +206,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
return nil, retryErr
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -256,7 +247,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
cfg := config.Get()
if cfg.Debug {
jsonData, _ := json.Marshal(params)
slog.Debug("Prepared messages", "messages", string(jsonData))
logging.Debug("Prepared messages", "messages", string(jsonData))
}
attempts := 0
@@ -321,7 +312,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
return
}
if retry {
status.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
logging.WarnPersist(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries), logging.PersistTimeArg, time.Millisecond*time.Duration(after+100))
select {
case <-ctx.Done():
// context cancelled
@@ -427,7 +418,7 @@ func WithReasoningEffort(effort string) OpenAIOption {
case "low", "medium", "high":
defaultReasoningEffort = effort
default:
slog.Warn("Invalid reasoning effort, using default: medium")
logging.Warn("Invalid reasoning effort, using default: medium")
}
options.reasoningEffort = defaultReasoningEffort
}
+5 -47
View File
@@ -4,10 +4,9 @@ import (
"context"
"fmt"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"log/slog"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/message"
)
type EventType string
@@ -56,8 +55,6 @@ type Provider interface {
StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
Model() models.Model
MaxTokens() int64
}
type providerClientOptions struct {
@@ -164,55 +161,16 @@ func (p *baseProvider[C]) cleanMessages(messages []message.Message) (cleaned []m
func (p *baseProvider[C]) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
messages = p.cleanMessages(messages)
response, err := p.client.send(ctx, messages, tools)
if err == nil && response != nil {
slog.Debug("API request token usage",
"model", p.options.model.Name,
"input_tokens", response.Usage.InputTokens,
"output_tokens", response.Usage.OutputTokens,
"cache_creation_tokens", response.Usage.CacheCreationTokens,
"cache_read_tokens", response.Usage.CacheReadTokens,
"total_tokens", response.Usage.InputTokens+response.Usage.OutputTokens)
}
return response, err
return p.client.send(ctx, messages, tools)
}
func (p *baseProvider[C]) Model() models.Model {
return p.options.model
}
func (p *baseProvider[C]) MaxTokens() int64 {
return p.options.maxTokens
}
func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent {
messages = p.cleanMessages(messages)
eventChan := p.client.stream(ctx, messages, tools)
// Create a new channel to intercept events
wrappedChan := make(chan ProviderEvent)
go func() {
defer close(wrappedChan)
for event := range eventChan {
// Pass the event through
wrappedChan <- event
// Log token usage when we get the complete event
if event.Type == EventComplete && event.Response != nil {
slog.Debug("API streaming request token usage",
"model", p.options.model.Name,
"input_tokens", event.Response.Usage.InputTokens,
"output_tokens", event.Response.Usage.OutputTokens,
"cache_creation_tokens", event.Response.Usage.CacheCreationTokens,
"cache_read_tokens", event.Response.Usage.CacheReadTokens,
"total_tokens", event.Response.Usage.InputTokens+event.Response.Usage.OutputTokens)
}
}
}()
return wrappedChan
return p.client.stream(ctx, messages, tools)
}
func WithAPIKey(apiKey string) ProviderClientOption {
+3 -4
View File
@@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/tools/shell"
"github.com/sst/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools/shell"
"github.com/opencode-ai/opencode/internal/permission"
)
type BashParams struct {
@@ -268,7 +268,6 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
if !isSafeReadOnly {
p := b.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
@@ -9,8 +9,8 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
type DiagnosticsParams struct {
@@ -74,8 +74,7 @@ func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse,
lsps := b.lspClients
if len(lsps) == 0 {
// Return a more helpful message when LSP clients aren't ready yet
return NewTextResponse("\n<diagnostic_summary>\nLSP clients are still initializing. Diagnostics will be available once they're ready.\n</diagnostic_summary>\n"), nil
return NewTextErrorResponse("no LSP clients available"), nil
}
if params.FilePath != "" {
+23 -26
View File
@@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/permission"
)
type EditParams struct {
@@ -37,7 +37,7 @@ type EditResponseMetadata struct {
type editTool struct {
lspClients map[string]*lsp.Client
permissions permission.Service
history history.Service
files history.Service
}
const (
@@ -95,7 +95,7 @@ func NewEditTool(lspClients map[string]*lsp.Client, permissions permission.Servi
return &editTool{
lspClients: lspClients,
permissions: permissions,
history: files,
files: files,
}
}
@@ -202,7 +202,6 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -225,17 +224,17 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
}
// File can't be in the history so we create a new file history
_, err = e.history.Create(ctx, sessionID, filePath, "")
_, err = e.files.Create(ctx, sessionID, filePath, "")
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
}
// Add the new content to the file history
_, err = e.history.CreateVersion(ctx, sessionID, filePath, content)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, content)
if err != nil {
// Log error but don't fail the operation
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
@@ -314,7 +313,6 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -337,9 +335,9 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
}
// Check if file exists in history
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
@@ -347,15 +345,15 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
}
if file.Content != oldContent {
// User Manually changed the content store an intermediate version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, "")
_, err = e.files.CreateVersion(ctx, sessionID, filePath, "")
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
@@ -435,7 +433,6 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
permissionPath = rootDir
}
p := e.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -458,9 +455,9 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
}
// Check if file exists in history
file, err := e.history.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := e.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = e.history.Create(ctx, sessionID, filePath, oldContent)
_, err = e.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
// Log error but don't fail the operation
return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
@@ -468,15 +465,15 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
}
if file.Content != oldContent {
// User Manually changed the content store an intermediate version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, oldContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = e.history.CreateVersion(ctx, sessionID, filePath, newContent)
_, err = e.files.CreateVersion(ctx, sessionID, filePath, newContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
+2 -3
View File
@@ -11,8 +11,8 @@ import (
md "github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/permission"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/permission"
)
type FetchParams struct {
@@ -122,7 +122,6 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
}
p := t.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: config.WorkingDirectory(),
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/sst/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/config"
)
const (
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/config"
)
type GrepParams struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/config"
)
type LSParams struct {
+23 -23
View File
@@ -83,19 +83,19 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// Check that visible directories and files are included
assert.Contains(t, response.Content, "dir1")
assert.Contains(t, response.Content, "dir2")
assert.Contains(t, response.Content, "dir3")
assert.Contains(t, response.Content, "file1.txt")
assert.Contains(t, response.Content, "file2.txt")
// Check that hidden files and directories are not included
assert.NotContains(t, response.Content, ".hidden_dir")
assert.NotContains(t, response.Content, ".hidden_file.txt")
assert.NotContains(t, response.Content, ".hidden_root_file.txt")
// Check that __pycache__ is not included
assert.NotContains(t, response.Content, "__pycache__")
})
@@ -122,7 +122,7 @@ func TestLsTool_Run(t *testing.T) {
t.Run("handles empty path parameter", func(t *testing.T) {
// For this test, we need to mock the config.WorkingDirectory function
// Since we can't easily do that, we'll just check that the response doesn't contain an error message
tool := NewLsTool()
params := LSParams{
Path: "",
@@ -138,7 +138,7 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// The response should either contain a valid directory listing or an error
// We'll just check that it's not empty
assert.NotEmpty(t, response.Content)
@@ -173,11 +173,11 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// The output format is a tree, so we need to check for specific patterns
// Check that file1.txt is not directly mentioned
assert.NotContains(t, response.Content, "- file1.txt")
// Check that dir1/ is not directly mentioned
assert.NotContains(t, response.Content, "- dir1/")
})
@@ -189,12 +189,12 @@ func TestLsTool_Run(t *testing.T) {
defer func() {
os.Chdir(origWd)
}()
// Change to a directory above the temp directory
parentDir := filepath.Dir(tempDir)
err = os.Chdir(parentDir)
require.NoError(t, err)
tool := NewLsTool()
params := LSParams{
Path: filepath.Base(tempDir),
@@ -210,7 +210,7 @@ func TestLsTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
// Should list the temp directory contents
assert.Contains(t, response.Content, "dir1")
assert.Contains(t, response.Content, "file1.txt")
@@ -291,22 +291,22 @@ func TestCreateFileTree(t *testing.T) {
}
tree := createFileTree(paths)
// Check the structure of the tree
assert.Len(t, tree, 1) // Should have one root node
// Check the root node
rootNode := tree[0]
assert.Equal(t, "path", rootNode.Name)
assert.Equal(t, "directory", rootNode.Type)
assert.Len(t, rootNode.Children, 1)
// Check the "to" node
toNode := rootNode.Children[0]
assert.Equal(t, "to", toNode.Name)
assert.Equal(t, "directory", toNode.Type)
assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2
// Find the dir1 node
var dir1Node *TreeNode
for _, child := range toNode.Children {
@@ -315,7 +315,7 @@ func TestCreateFileTree(t *testing.T) {
break
}
}
require.NotNil(t, dir1Node)
assert.Equal(t, "directory", dir1Node.Type)
assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir
@@ -354,9 +354,9 @@ func TestPrintTree(t *testing.T) {
Type: "file",
},
}
result := printTree(tree, "/root")
// Check the output format
assert.Contains(t, result, "- /root/")
assert.Contains(t, result, " - dir1/")
@@ -405,7 +405,7 @@ func TestListDirectory(t *testing.T) {
files, truncated, err := listDirectory(tempDir, []string{}, 1000)
require.NoError(t, err)
assert.False(t, truncated)
// Check that visible files and directories are included
containsPath := func(paths []string, target string) bool {
targetPath := filepath.Join(tempDir, target)
@@ -416,12 +416,12 @@ func TestListDirectory(t *testing.T) {
}
return false
}
assert.True(t, containsPath(files, "dir1"))
assert.True(t, containsPath(files, "file1.txt"))
assert.True(t, containsPath(files, "file2.txt"))
assert.True(t, containsPath(files, "dir1/file3.txt"))
// Check that hidden files and directories are not included
assert.False(t, containsPath(files, ".hidden_dir"))
assert.False(t, containsPath(files, ".hidden_file.txt"))
@@ -438,12 +438,12 @@ func TestListDirectory(t *testing.T) {
files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000)
require.NoError(t, err)
assert.False(t, truncated)
// Check that no .txt files are included
for _, file := range files {
assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file)
}
// But directories should still be included
containsDir := false
for _, file := range files {
@@ -454,4 +454,4 @@ func TestListDirectory(t *testing.T) {
}
assert.True(t, containsDir)
})
}
}
-350
View File
@@ -1,350 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
)
type CodeActionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
EndLine int `json:"end_line,omitempty"`
EndColumn int `json:"end_column,omitempty"`
ActionID int `json:"action_id,omitempty"`
LspName string `json:"lsp_name,omitempty"`
}
type codeActionTool struct {
lspClients map[string]*lsp.Client
}
const (
CodeActionToolName = "codeAction"
codeActionDescription = `Get available code actions at a specific position or range in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find available fixes or refactorings for code issues
- Helpful for resolving errors, warnings, or improving code quality
- Great for discovering automated code transformations
HOW TO USE:
- Provide the path to the file containing the code
- Specify the line number (1-based) where the action should be applied
- Specify the column number (1-based) where the action should be applied
- Optionally specify end_line and end_column to define a range
- Results show available code actions with their titles and kinds
TO EXECUTE A CODE ACTION:
- After getting the list of available actions, call the tool again with the same parameters
- Add action_id parameter with the number of the action you want to execute (e.g., 1 for the first action)
- Add lsp_name parameter with the name of the LSP server that provided the action
FEATURES:
- Finds quick fixes for errors and warnings
- Discovers available refactorings
- Shows code organization actions
- Returns detailed information about each action
- Can execute selected code actions
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all code issues depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Diagnostics tool to find issues that can be fixed
- First call without action_id to see available actions, then call again with action_id to execute
`
)
func NewCodeActionTool(lspClients map[string]*lsp.Client) BaseTool {
return &codeActionTool{
lspClients,
}
}
func (b *codeActionTool) Info() ToolInfo {
return ToolInfo{
Name: CodeActionToolName,
Description: codeActionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the code",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the action should be applied",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the action should be applied",
},
"end_line": map[string]any{
"type": "integer",
"description": "The ending line number (1-based) for a range (optional)",
},
"end_column": map[string]any{
"type": "integer",
"description": "The ending column number (1-based) for a range (optional)",
},
"action_id": map[string]any{
"type": "integer",
"description": "The ID of the code action to execute (optional)",
},
"lsp_name": map[string]any{
"type": "string",
"description": "The name of the LSP server that provided the action (optional)",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *codeActionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params CodeActionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Code actions will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
// Handle optional end line/column
endLine := line
endColumn := column
if params.EndLine > 0 {
endLine = max(0, params.EndLine-1)
}
if params.EndColumn > 0 {
endColumn = max(0, params.EndColumn-1)
}
// Check if we're executing a specific action
if params.ActionID > 0 && params.LspName != "" {
return executeCodeAction(ctx, params.FilePath, line, column, endLine, endColumn, params.ActionID, params.LspName, lsps)
}
// Otherwise, just list available actions
output := getCodeActions(ctx, params.FilePath, line, column, endLine, endColumn, lsps)
return NewTextResponse(output), nil
}
func getCodeActions(ctx context.Context, filePath string, line, column, endLine, endColumn int, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(codeActions) == 0 {
results = append(results, fmt.Sprintf("No code actions found by %s", lspName))
continue
}
// Format the code actions
results = append(results, fmt.Sprintf("Code actions found by %s:", lspName))
for i, action := range codeActions {
actionInfo := formatCodeAction(action, i+1)
results = append(results, actionInfo)
}
}
if len(results) == 0 {
return "No code actions found at the specified position."
}
return strings.Join(results, "\n")
}
func formatCodeAction(action protocol.Or_Result_textDocument_codeAction_Item0_Elem, index int) string {
switch v := action.Value.(type) {
case protocol.CodeAction:
kind := "Unknown"
if v.Kind != "" {
kind = string(v.Kind)
}
var details []string
// Add edit information if available
if v.Edit != nil {
numChanges := 0
if v.Edit.Changes != nil {
numChanges = len(v.Edit.Changes)
}
if v.Edit.DocumentChanges != nil {
numChanges = len(v.Edit.DocumentChanges)
}
details = append(details, fmt.Sprintf("Edits: %d changes", numChanges))
}
// Add command information if available
if v.Command != nil {
details = append(details, fmt.Sprintf("Command: %s", v.Command.Title))
}
// Add diagnostics information if available
if v.Diagnostics != nil && len(v.Diagnostics) > 0 {
details = append(details, fmt.Sprintf("Fixes: %d diagnostics", len(v.Diagnostics)))
}
detailsStr := ""
if len(details) > 0 {
detailsStr = " (" + strings.Join(details, ", ") + ")"
}
return fmt.Sprintf(" %d. %s [%s]%s", index, v.Title, kind, detailsStr)
case protocol.Command:
return fmt.Sprintf(" %d. %s [Command]", index, v.Title)
}
return fmt.Sprintf(" %d. Unknown code action type", index)
}
func executeCodeAction(ctx context.Context, filePath string, line, column, endLine, endColumn, actionID int, lspName string, lsps map[string]*lsp.Client) (ToolResponse, error) {
client, ok := lsps[lspName]
if !ok {
return NewTextErrorResponse(fmt.Sprintf("LSP server '%s' not found", lspName)), nil
}
// Create code action params
uri := fmt.Sprintf("file://%s", filePath)
codeActionParams := protocol.CodeActionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Range: protocol.Range{
Start: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
End: protocol.Position{
Line: uint32(endLine),
Character: uint32(endColumn),
},
},
Context: protocol.CodeActionContext{
// Request all kinds of code actions
Only: []protocol.CodeActionKind{
protocol.QuickFix,
protocol.Refactor,
protocol.RefactorExtract,
protocol.RefactorInline,
protocol.RefactorRewrite,
protocol.Source,
protocol.SourceOrganizeImports,
protocol.SourceFixAll,
},
},
}
// Get code actions
codeActions, err := client.CodeAction(ctx, codeActionParams)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error getting code actions: %s", err)), nil
}
if len(codeActions) == 0 {
return NewTextErrorResponse("No code actions found"), nil
}
// Check if the requested action ID is valid
if actionID < 1 || actionID > len(codeActions) {
return NewTextErrorResponse(fmt.Sprintf("Invalid action ID: %d. Available actions: 1-%d", actionID, len(codeActions))), nil
}
// Get the selected action (adjust for 0-based index)
selectedAction := codeActions[actionID-1]
// Execute the action based on its type
switch v := selectedAction.Value.(type) {
case protocol.CodeAction:
// Apply workspace edit if available
if v.Edit != nil {
err := util.ApplyWorkspaceEdit(*v.Edit)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error applying edit: %s", err)), nil
}
}
// Execute command if available
if v.Command != nil {
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command.Command,
Arguments: v.Command.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
}
return NewTextResponse(fmt.Sprintf("Successfully executed code action: %s", v.Title)), nil
case protocol.Command:
// Execute the command
_, err := client.ExecuteCommand(ctx, protocol.ExecuteCommandParams{
Command: v.Command,
Arguments: v.Arguments,
})
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("Error executing command: %s", err)), nil
}
return NewTextResponse(fmt.Sprintf("Successfully executed command: %s", v.Title)), nil
}
return NewTextErrorResponse("Unknown code action type"), nil
}
-198
View File
@@ -1,198 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type DefinitionParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
}
type definitionTool struct {
lspClients map[string]*lsp.Client
}
const (
DefinitionToolName = "definition"
definitionDescription = `Find the definition of a symbol at a specific position in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find where a symbol is defined
- Helpful for understanding code structure and relationships
- Great for navigating between implementation and interface
HOW TO USE:
- Provide the path to the file containing the symbol
- Specify the line number (1-based) where the symbol appears
- Specify the column number (1-based) where the symbol appears
- Results show the location of the symbol's definition
FEATURES:
- Finds definitions across files in the project
- Works with variables, functions, classes, interfaces, etc.
- Returns file path, line, and column of the definition
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not work for all symbols depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with References tool to understand usage
- Combine with View tool to examine the definition
`
)
func NewDefinitionTool(lspClients map[string]*lsp.Client) BaseTool {
return &definitionTool{
lspClients,
}
}
func (b *definitionTool) Info() ToolInfo {
return ToolInfo{
Name: DefinitionToolName,
Description: definitionDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the symbol",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the symbol appears",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the symbol appears",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *definitionTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params DefinitionParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Definition lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
output := getDefinition(ctx, params.FilePath, line, column, lsps)
return NewTextResponse(output), nil
}
func getDefinition(ctx context.Context, filePath string, line, column int, lsps map[string]*lsp.Client) string {
var results []string
slog.Debug(fmt.Sprintf("Looking for definition in %s at line %d, column %d", filePath, line+1, column+1))
slog.Debug(fmt.Sprintf("Available LSP clients: %v", getClientNames(lsps)))
for lspName, client := range lsps {
slog.Debug(fmt.Sprintf("Trying LSP client: %s", lspName))
// Create definition params
uri := fmt.Sprintf("file://%s", filePath)
definitionParams := protocol.DefinitionParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Position: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
},
}
slog.Debug(fmt.Sprintf("Sending definition request with params: %+v", definitionParams))
// Get definition
definition, err := client.Definition(ctx, definitionParams)
if err != nil {
slog.Debug(fmt.Sprintf("Error from %s: %s", lspName, err))
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
slog.Debug(fmt.Sprintf("Got definition result type: %T", definition.Value))
// Process the definition result
locations := processDefinitionResult(definition)
slog.Debug(fmt.Sprintf("Processed locations count: %d", len(locations)))
if len(locations) == 0 {
results = append(results, fmt.Sprintf("No definition found by %s", lspName))
continue
}
// Format the locations
for _, loc := range locations {
path := strings.TrimPrefix(string(loc.URI), "file://")
// Convert 0-based line/column to 1-based for display
defLine := loc.Range.Start.Line + 1
defColumn := loc.Range.Start.Character + 1
slog.Debug(fmt.Sprintf("Found definition at %s:%d:%d", path, defLine, defColumn))
results = append(results, fmt.Sprintf("Definition found by %s: %s:%d:%d", lspName, path, defLine, defColumn))
}
}
if len(results) == 0 {
return "No definition found for the symbol at the specified position."
}
return strings.Join(results, "\n")
}
func processDefinitionResult(result protocol.Or_Result_textDocument_definition) []protocol.Location {
var locations []protocol.Location
switch v := result.Value.(type) {
case protocol.Location:
locations = append(locations, v)
case []protocol.Location:
locations = append(locations, v...)
case []protocol.DefinitionLink:
for _, link := range v {
locations = append(locations, protocol.Location{
URI: link.TargetURI,
Range: link.TargetRange,
})
}
case protocol.Or_Definition:
switch d := v.Value.(type) {
case protocol.Location:
locations = append(locations, d)
case []protocol.Location:
locations = append(locations, d...)
}
}
return locations
}
// Helper function to get LSP client names for debugging
func getClientNames(lsps map[string]*lsp.Client) []string {
names := make([]string, 0, len(lsps))
for name := range lsps {
names = append(names, name)
}
return names
}
-204
View File
@@ -1,204 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type DocSymbolsParams struct {
FilePath string `json:"file_path"`
}
type docSymbolsTool struct {
lspClients map[string]*lsp.Client
}
const (
DocSymbolsToolName = "docSymbols"
docSymbolsDescription = `Get document symbols for a file.
WHEN TO USE THIS TOOL:
- Use when you need to understand the structure of a file
- Helpful for finding classes, functions, methods, and variables in a file
- Great for getting an overview of a file's organization
HOW TO USE:
- Provide the path to the file to get symbols for
- Results show all symbols defined in the file with their kind and location
FEATURES:
- Lists all symbols in a hierarchical structure
- Shows symbol types (function, class, variable, etc.)
- Provides location information for each symbol
- Organizes symbols by their scope and relationship
LIMITATIONS:
- Requires a functioning LSP server for the file type
- Results depend on the accuracy of the LSP server
- May not work for all file types
TIPS:
- Use to quickly understand the structure of a large file
- Combine with Definition and References tools for deeper code exploration
`
)
func NewDocSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
return &docSymbolsTool{
lspClients,
}
}
func (b *docSymbolsTool) Info() ToolInfo {
return ToolInfo{
Name: DocSymbolsToolName,
Description: docSymbolsDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file to get symbols for",
},
},
Required: []string{"file_path"},
}
}
func (b *docSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params DocSymbolsParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Document symbols lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
output := getDocumentSymbols(ctx, params.FilePath, lsps)
return NewTextResponse(output), nil
}
func getDocumentSymbols(ctx context.Context, filePath string, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create document symbol params
uri := fmt.Sprintf("file://%s", filePath)
symbolParams := protocol.DocumentSymbolParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
}
// Get document symbols
symbolResult, err := client.DocumentSymbol(ctx, symbolParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
// Process the symbol result
symbols := processDocumentSymbolResult(symbolResult)
if len(symbols) == 0 {
results = append(results, fmt.Sprintf("No symbols found by %s", lspName))
continue
}
// Format the symbols
results = append(results, fmt.Sprintf("Symbols found by %s:", lspName))
for _, symbol := range symbols {
results = append(results, formatSymbol(symbol, 1))
}
}
if len(results) == 0 {
return "No symbols found in the specified file."
}
return strings.Join(results, "\n")
}
func processDocumentSymbolResult(result protocol.Or_Result_textDocument_documentSymbol) []SymbolInfo {
var symbols []SymbolInfo
switch v := result.Value.(type) {
case []protocol.SymbolInformation:
for _, si := range v {
symbols = append(symbols, SymbolInfo{
Name: si.Name,
Kind: symbolKindToString(si.Kind),
Location: locationToString(si.Location),
Children: nil,
})
}
case []protocol.DocumentSymbol:
for _, ds := range v {
symbols = append(symbols, documentSymbolToSymbolInfo(ds))
}
}
return symbols
}
// SymbolInfo represents a symbol in a document
type SymbolInfo struct {
Name string
Kind string
Location string
Children []SymbolInfo
}
func documentSymbolToSymbolInfo(symbol protocol.DocumentSymbol) SymbolInfo {
info := SymbolInfo{
Name: symbol.Name,
Kind: symbolKindToString(symbol.Kind),
Location: fmt.Sprintf("Line %d-%d",
symbol.Range.Start.Line+1,
symbol.Range.End.Line+1),
Children: []SymbolInfo{},
}
for _, child := range symbol.Children {
info.Children = append(info.Children, documentSymbolToSymbolInfo(child))
}
return info
}
func locationToString(location protocol.Location) string {
return fmt.Sprintf("Line %d-%d",
location.Range.Start.Line+1,
location.Range.End.Line+1)
}
func symbolKindToString(kind protocol.SymbolKind) string {
if kindStr, ok := protocol.TableKindMap[kind]; ok {
return kindStr
}
return "Unknown"
}
func formatSymbol(symbol SymbolInfo, level int) string {
indent := strings.Repeat(" ", level)
result := fmt.Sprintf("%s- %s (%s) %s", indent, symbol.Name, symbol.Kind, symbol.Location)
var childResults []string
for _, child := range symbol.Children {
childResults = append(childResults, formatSymbol(child, level+1))
}
if len(childResults) > 0 {
return result + "\n" + strings.Join(childResults, "\n")
}
return result
}
-161
View File
@@ -1,161 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type ReferencesParams struct {
FilePath string `json:"file_path"`
Line int `json:"line"`
Column int `json:"column"`
IncludeDeclaration bool `json:"include_declaration"`
}
type referencesTool struct {
lspClients map[string]*lsp.Client
}
const (
ReferencesToolName = "references"
referencesDescription = `Find all references to a symbol at a specific position in a file.
WHEN TO USE THIS TOOL:
- Use when you need to find all places where a symbol is used
- Helpful for understanding code usage and dependencies
- Great for refactoring and impact analysis
HOW TO USE:
- Provide the path to the file containing the symbol
- Specify the line number (1-based) where the symbol appears
- Specify the column number (1-based) where the symbol appears
- Optionally set include_declaration to include the declaration in results
- Results show all locations where the symbol is referenced
FEATURES:
- Finds references across files in the project
- Works with variables, functions, classes, interfaces, etc.
- Returns file paths, lines, and columns of all references
LIMITATIONS:
- Requires a functioning LSP server for the file type
- May not find all references depending on LSP capabilities
- Results depend on the accuracy of the LSP server
TIPS:
- Use in conjunction with Definition tool to understand symbol origins
- Combine with View tool to examine the references
`
)
func NewReferencesTool(lspClients map[string]*lsp.Client) BaseTool {
return &referencesTool{
lspClients,
}
}
func (b *referencesTool) Info() ToolInfo {
return ToolInfo{
Name: ReferencesToolName,
Description: referencesDescription,
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file containing the symbol",
},
"line": map[string]any{
"type": "integer",
"description": "The line number (1-based) where the symbol appears",
},
"column": map[string]any{
"type": "integer",
"description": "The column number (1-based) where the symbol appears",
},
"include_declaration": map[string]any{
"type": "boolean",
"description": "Whether to include the declaration in the results",
},
},
Required: []string{"file_path", "line", "column"},
}
}
func (b *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params ReferencesParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. References lookup will be available once they're ready.\n"), nil
}
// Ensure file is open in LSP
notifyLspOpenFile(ctx, params.FilePath, lsps)
// Convert 1-based line/column to 0-based for LSP protocol
line := max(0, params.Line-1)
column := max(0, params.Column-1)
output := getReferences(ctx, params.FilePath, line, column, params.IncludeDeclaration, lsps)
return NewTextResponse(output), nil
}
func getReferences(ctx context.Context, filePath string, line, column int, includeDeclaration bool, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create references params
uri := fmt.Sprintf("file://%s", filePath)
referencesParams := protocol.ReferenceParams{
TextDocumentPositionParams: protocol.TextDocumentPositionParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Position: protocol.Position{
Line: uint32(line),
Character: uint32(column),
},
},
Context: protocol.ReferenceContext{
IncludeDeclaration: includeDeclaration,
},
}
// Get references
references, err := client.References(ctx, referencesParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
if len(references) == 0 {
results = append(results, fmt.Sprintf("No references found by %s", lspName))
continue
}
// Format the locations
results = append(results, fmt.Sprintf("References found by %s:", lspName))
for _, loc := range references {
path := strings.TrimPrefix(string(loc.URI), "file://")
// Convert 0-based line/column to 1-based for display
refLine := loc.Range.Start.Line + 1
refColumn := loc.Range.Start.Character + 1
results = append(results, fmt.Sprintf(" %s:%d:%d", path, refLine, refColumn))
}
}
if len(results) == 0 {
return "No references found for the symbol at the specified position."
}
return strings.Join(results, "\n")
}
-162
View File
@@ -1,162 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
)
type WorkspaceSymbolsParams struct {
Query string `json:"query"`
}
type workspaceSymbolsTool struct {
lspClients map[string]*lsp.Client
}
const (
WorkspaceSymbolsToolName = "workspaceSymbols"
workspaceSymbolsDescription = `Find symbols across the workspace matching a query.
WHEN TO USE THIS TOOL:
- Use when you need to find symbols across multiple files
- Helpful for locating classes, functions, or variables in a project
- Great for exploring large codebases
HOW TO USE:
- Provide a query string to search for symbols
- Results show matching symbols from across the workspace
FEATURES:
- Searches across all files in the workspace
- Shows symbol types (function, class, variable, etc.)
- Provides location information for each symbol
- Works with partial matches and fuzzy search (depending on LSP server)
LIMITATIONS:
- Requires a functioning LSP server for the file types
- Results depend on the accuracy of the LSP server
- Query capabilities vary by language server
- May not work for all file types
TIPS:
- Use specific queries to narrow down results
- Combine with DocSymbols tool for detailed file exploration
- Use with Definition tool to jump to symbol definitions
`
)
func NewWorkspaceSymbolsTool(lspClients map[string]*lsp.Client) BaseTool {
return &workspaceSymbolsTool{
lspClients,
}
}
func (b *workspaceSymbolsTool) Info() ToolInfo {
return ToolInfo{
Name: WorkspaceSymbolsToolName,
Description: workspaceSymbolsDescription,
Parameters: map[string]any{
"query": map[string]any{
"type": "string",
"description": "The query string to search for symbols",
},
},
Required: []string{"query"},
}
}
func (b *workspaceSymbolsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params WorkspaceSymbolsParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextResponse("\nLSP clients are still initializing. Workspace symbols lookup will be available once they're ready.\n"), nil
}
output := getWorkspaceSymbols(ctx, params.Query, lsps)
return NewTextResponse(output), nil
}
func getWorkspaceSymbols(ctx context.Context, query string, lsps map[string]*lsp.Client) string {
var results []string
for lspName, client := range lsps {
// Create workspace symbol params
symbolParams := protocol.WorkspaceSymbolParams{
Query: query,
}
// Get workspace symbols
symbolResult, err := client.Symbol(ctx, symbolParams)
if err != nil {
results = append(results, fmt.Sprintf("Error from %s: %s", lspName, err))
continue
}
// Process the symbol result
symbols := processWorkspaceSymbolResult(symbolResult)
if len(symbols) == 0 {
results = append(results, fmt.Sprintf("No symbols found by %s for query '%s'", lspName, query))
continue
}
// Format the symbols
results = append(results, fmt.Sprintf("Symbols found by %s for query '%s':", lspName, query))
for _, symbol := range symbols {
results = append(results, fmt.Sprintf(" %s (%s) - %s", symbol.Name, symbol.Kind, symbol.Location))
}
}
if len(results) == 0 {
return fmt.Sprintf("No symbols found matching query '%s'.", query)
}
return strings.Join(results, "\n")
}
func processWorkspaceSymbolResult(result protocol.Or_Result_workspace_symbol) []SymbolInfo {
var symbols []SymbolInfo
switch v := result.Value.(type) {
case []protocol.SymbolInformation:
for _, si := range v {
symbols = append(symbols, SymbolInfo{
Name: si.Name,
Kind: symbolKindToString(si.Kind),
Location: formatWorkspaceLocation(si.Location),
Children: nil,
})
}
case []protocol.WorkspaceSymbol:
for _, ws := range v {
location := "Unknown location"
if ws.Location.Value != nil {
if loc, ok := ws.Location.Value.(protocol.Location); ok {
location = formatWorkspaceLocation(loc)
}
}
symbols = append(symbols, SymbolInfo{
Name: ws.Name,
Kind: symbolKindToString(ws.Kind),
Location: location,
Children: nil,
})
}
}
return symbols
}
func formatWorkspaceLocation(location protocol.Location) string {
path := strings.TrimPrefix(string(location.URI), "file://")
return fmt.Sprintf("%s:%d:%d", path, location.Range.Start.Line+1, location.Range.Start.Character+1)
}
+10 -13
View File
@@ -8,12 +8,12 @@ import (
"path/filepath"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/permission"
)
type PatchParams struct {
@@ -193,7 +193,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
dir := filepath.Dir(path)
patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -221,7 +220,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
dir := filepath.Dir(path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -241,7 +239,6 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
dir := filepath.Dir(path)
patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
p := p.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: dir,
@@ -316,12 +313,12 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
totalRemovals += removals
// Update history
file, err := p.files.GetLatestByPathAndSession(ctx, absPath, sessionID)
file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
if err != nil && change.Type != diff.ActionAdd {
// If not adding a file, create history entry for existing file
_, err = p.files.Create(ctx, sessionID, absPath, oldContent)
if err != nil {
slog.Debug("Error creating file history", "error", err)
logging.Debug("Error creating file history", "error", err)
}
}
@@ -329,7 +326,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
// User manually changed content, store intermediate version
_, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
@@ -340,7 +337,7 @@ func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
_, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
}
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
// Record file operations
+1 -3
View File
@@ -11,8 +11,6 @@ import (
"sync"
"syscall"
"time"
"github.com/sst/opencode/internal/status"
)
type PersistentShell struct {
@@ -101,7 +99,7 @@ func newPersistentShell(cwd string) *PersistentShell {
go func() {
err := cmd.Wait()
if err != nil {
status.Error(fmt.Sprintf("Shell process exited with error: %v", err))
// Log the error if needed
}
shell.isAlive = false
close(shell.commandQueue)
+383
View File
@@ -0,0 +1,383 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type SourcegraphParams struct {
Query string `json:"query"`
Count int `json:"count,omitempty"`
ContextWindow int `json:"context_window,omitempty"`
Timeout int `json:"timeout,omitempty"`
}
type SourcegraphResponseMetadata struct {
NumberOfMatches int `json:"number_of_matches"`
Truncated bool `json:"truncated"`
}
type sourcegraphTool struct {
client *http.Client
}
const (
SourcegraphToolName = "sourcegraph"
sourcegraphToolDescription = `Search code across public repositories using Sourcegraph's GraphQL API.
WHEN TO USE THIS TOOL:
- Use when you need to find code examples or implementations across public repositories
- Helpful for researching how others have solved similar problems
- Useful for discovering patterns and best practices in open source code
HOW TO USE:
- Provide a search query using Sourcegraph's query syntax
- Optionally specify the number of results to return (default: 10)
- Optionally set a timeout for the request
QUERY SYNTAX:
- Basic search: "fmt.Println" searches for exact matches
- File filters: "file:.go fmt.Println" limits to Go files
- Repository filters: "repo:^github\.com/golang/go$ fmt.Println" limits to specific repos
- Language filters: "lang:go fmt.Println" limits to Go code
- Boolean operators: "fmt.Println AND log.Fatal" for combined terms
- Regular expressions: "fmt\.(Print|Printf|Println)" for pattern matching
- Quoted strings: "\"exact phrase\"" for exact phrase matching
- Exclude filters: "-file:test" or "-repo:forks" to exclude matches
ADVANCED FILTERS:
- Repository filters:
* "repo:name" - Match repositories with name containing "name"
* "repo:^github\.com/org/repo$" - Exact repository match
* "repo:org/repo@branch" - Search specific branch
* "repo:org/repo rev:branch" - Alternative branch syntax
* "-repo:name" - Exclude repositories
* "fork:yes" or "fork:only" - Include or only show forks
* "archived:yes" or "archived:only" - Include or only show archived repos
* "visibility:public" or "visibility:private" - Filter by visibility
- File filters:
* "file:\.js$" - Files with .js extension
* "file:internal/" - Files in internal directory
* "-file:test" - Exclude test files
* "file:has.content(Copyright)" - Files containing "Copyright"
* "file:has.contributor([email protected])" - Files with specific contributor
- Content filters:
* "content:\"exact string\"" - Search for exact string
* "-content:\"unwanted\"" - Exclude files with unwanted content
* "case:yes" - Case-sensitive search
- Type filters:
* "type:symbol" - Search for symbols (functions, classes, etc.)
* "type:file" - Search file content only
* "type:path" - Search filenames only
* "type:diff" - Search code changes
* "type:commit" - Search commit messages
- Commit/diff search:
* "after:\"1 month ago\"" - Commits after date
* "before:\"2023-01-01\"" - Commits before date
* "author:name" - Commits by author
* "message:\"fix bug\"" - Commits with message
- Result selection:
* "select:repo" - Show only repository names
* "select:file" - Show only file paths
* "select:content" - Show only matching content
* "select:symbol" - Show only matching symbols
- Result control:
* "count:100" - Return up to 100 results
* "count:all" - Return all results
* "timeout:30s" - Set search timeout
EXAMPLES:
- "file:.go context.WithTimeout" - Find Go code using context.WithTimeout
- "lang:typescript useState type:symbol" - Find TypeScript React useState hooks
- "repo:^github\.com/kubernetes/kubernetes$ pod list type:file" - Find Kubernetes files related to pod listing
- "repo:sourcegraph/sourcegraph$ after:\"3 months ago\" type:diff database" - Recent changes to database code
- "file:Dockerfile (alpine OR ubuntu) -content:alpine:latest" - Dockerfiles with specific base images
- "repo:has.path(\.py) file:requirements.txt tensorflow" - Python projects using TensorFlow
BOOLEAN OPERATORS:
- "term1 AND term2" - Results containing both terms
- "term1 OR term2" - Results containing either term
- "term1 NOT term2" - Results with term1 but not term2
- "term1 and (term2 or term3)" - Grouping with parentheses
LIMITATIONS:
- Only searches public repositories
- Rate limits may apply
- Complex queries may take longer to execute
- Maximum of 20 results per query
TIPS:
- Use specific file extensions to narrow results
- Add repo: filters for more targeted searches
- Use type:symbol to find function/method definitions
- Use type:file to find relevant files`
)
func NewSourcegraphTool() BaseTool {
return &sourcegraphTool{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (t *sourcegraphTool) Info() ToolInfo {
return ToolInfo{
Name: SourcegraphToolName,
Description: sourcegraphToolDescription,
Parameters: map[string]any{
"query": map[string]any{
"type": "string",
"description": "The Sourcegraph search query",
},
"count": map[string]any{
"type": "number",
"description": "Optional number of results to return (default: 10, max: 20)",
},
"context_window": map[string]any{
"type": "number",
"description": "The context around the match to return (default: 10 lines)",
},
"timeout": map[string]any{
"type": "number",
"description": "Optional timeout in seconds (max 120)",
},
},
Required: []string{"query"},
}
}
func (t *sourcegraphTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params SourcegraphParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse("Failed to parse sourcegraph parameters: " + err.Error()), nil
}
if params.Query == "" {
return NewTextErrorResponse("Query parameter is required"), nil
}
if params.Count <= 0 {
params.Count = 10
} else if params.Count > 20 {
params.Count = 20 // Limit to 20 results
}
if params.ContextWindow <= 0 {
params.ContextWindow = 10 // Default context window
}
client := t.client
if params.Timeout > 0 {
maxTimeout := 120 // 2 minutes
if params.Timeout > maxTimeout {
params.Timeout = maxTimeout
}
client = &http.Client{
Timeout: time.Duration(params.Timeout) * time.Second,
}
}
type graphqlRequest struct {
Query string `json:"query"`
Variables struct {
Query string `json:"query"`
} `json:"variables"`
}
request := graphqlRequest{
Query: "query Search($query: String!) { search(query: $query, version: V2, patternType: keyword ) { results { matchCount, limitHit, resultCount, approximateResultCount, missing { name }, timedout { name }, indexUnavailable, results { __typename, ... on FileMatch { repository { name }, file { path, url, content }, lineMatches { preview, lineNumber, offsetAndLengths } } } } } }",
}
request.Variables.Query = params.Query
graphqlQueryBytes, err := json.Marshal(request)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to marshal GraphQL request: %w", err)
}
graphqlQuery := string(graphqlQueryBytes)
req, err := http.NewRequestWithContext(
ctx,
"POST",
"https://sourcegraph.com/.api/graphql",
bytes.NewBuffer([]byte(graphqlQuery)),
)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "opencode/1.0")
resp, err := client.Do(req)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to fetch URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
if len(body) > 0 {
return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d, response: %s", resp.StatusCode, string(body))), nil
}
return NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return ToolResponse{}, fmt.Errorf("failed to read response body: %w", err)
}
var result map[string]any
if err = json.Unmarshal(body, &result); err != nil {
return ToolResponse{}, fmt.Errorf("failed to unmarshal response: %w", err)
}
formattedResults, err := formatSourcegraphResults(result, params.ContextWindow)
if err != nil {
return NewTextErrorResponse("Failed to format results: " + err.Error()), nil
}
return NewTextResponse(formattedResults), nil
}
func formatSourcegraphResults(result map[string]any, contextWindow int) (string, error) {
var buffer strings.Builder
if errors, ok := result["errors"].([]any); ok && len(errors) > 0 {
buffer.WriteString("## Sourcegraph API Error\n\n")
for _, err := range errors {
if errMap, ok := err.(map[string]any); ok {
if message, ok := errMap["message"].(string); ok {
buffer.WriteString(fmt.Sprintf("- %s\n", message))
}
}
}
return buffer.String(), nil
}
data, ok := result["data"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing data field")
}
search, ok := data["search"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing search field")
}
searchResults, ok := search["results"].(map[string]any)
if !ok {
return "", fmt.Errorf("invalid response format: missing results field")
}
matchCount, _ := searchResults["matchCount"].(float64)
resultCount, _ := searchResults["resultCount"].(float64)
limitHit, _ := searchResults["limitHit"].(bool)
buffer.WriteString("# Sourcegraph Search Results\n\n")
buffer.WriteString(fmt.Sprintf("Found %d matches across %d results\n", int(matchCount), int(resultCount)))
if limitHit {
buffer.WriteString("(Result limit reached, try a more specific query)\n")
}
buffer.WriteString("\n")
results, ok := searchResults["results"].([]any)
if !ok || len(results) == 0 {
buffer.WriteString("No results found. Try a different query.\n")
return buffer.String(), nil
}
maxResults := 10
if len(results) > maxResults {
results = results[:maxResults]
}
for i, res := range results {
fileMatch, ok := res.(map[string]any)
if !ok {
continue
}
typeName, _ := fileMatch["__typename"].(string)
if typeName != "FileMatch" {
continue
}
repo, _ := fileMatch["repository"].(map[string]any)
file, _ := fileMatch["file"].(map[string]any)
lineMatches, _ := fileMatch["lineMatches"].([]any)
if repo == nil || file == nil {
continue
}
repoName, _ := repo["name"].(string)
filePath, _ := file["path"].(string)
fileURL, _ := file["url"].(string)
fileContent, _ := file["content"].(string)
buffer.WriteString(fmt.Sprintf("## Result %d: %s/%s\n\n", i+1, repoName, filePath))
if fileURL != "" {
buffer.WriteString(fmt.Sprintf("URL: %s\n\n", fileURL))
}
if len(lineMatches) > 0 {
for _, lm := range lineMatches {
lineMatch, ok := lm.(map[string]any)
if !ok {
continue
}
lineNumber, _ := lineMatch["lineNumber"].(float64)
preview, _ := lineMatch["preview"].(string)
if fileContent != "" {
lines := strings.Split(fileContent, "\n")
buffer.WriteString("```\n")
startLine := max(1, int(lineNumber)-contextWindow)
for j := startLine - 1; j < int(lineNumber)-1 && j < len(lines); j++ {
if j >= 0 {
buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
}
}
buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
endLine := int(lineNumber) + contextWindow
for j := int(lineNumber); j < endLine && j < len(lines); j++ {
if j < len(lines) {
buffer.WriteString(fmt.Sprintf("%d| %s\n", j+1, lines[j]))
}
}
buffer.WriteString("```\n\n")
} else {
buffer.WriteString("```\n")
buffer.WriteString(fmt.Sprintf("%d| %s\n", int(lineNumber), preview))
buffer.WriteString("```\n\n")
}
}
}
}
return buffer.String(), nil
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/lsp"
)
type ViewParams struct {
+9 -10
View File
@@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/permission"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/permission"
)
type WriteParams struct {
@@ -167,7 +167,6 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
permissionPath = rootDir
}
p := w.permissions.Request(
ctx,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: permissionPath,
@@ -190,7 +189,7 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
}
// Check if file exists in history
file, err := w.files.GetLatestByPathAndSession(ctx, filePath, sessionID)
file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
if err != nil {
_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
if err != nil {
@@ -202,13 +201,13 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
// User Manually changed the content store an intermediate version
_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
}
// Store the new version
_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
if err != nil {
slog.Debug("Error creating file history version", "error", err)
logging.Debug("Error creating file history version", "error", err)
}
recordFileWrite(filePath)
+78
View File
@@ -0,0 +1,78 @@
package logging
import (
"fmt"
"log/slog"
"os"
"runtime/debug"
"time"
)
func Info(msg string, args ...any) {
slog.Info(msg, args...)
}
func Debug(msg string, args ...any) {
slog.Debug(msg, args...)
}
func Warn(msg string, args ...any) {
slog.Warn(msg, args...)
}
func Error(msg string, args ...any) {
slog.Error(msg, args...)
}
func InfoPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Info(msg, args...)
}
func DebugPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Debug(msg, args...)
}
func WarnPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Warn(msg, args...)
}
func ErrorPersist(msg string, args ...any) {
args = append(args, persistKeyArg, true)
slog.Error(msg, args...)
}
// RecoverPanic is a common function to handle panics gracefully.
// It logs the error, creates a panic log file with stack trace,
// and executes an optional cleanup function before returning.
func RecoverPanic(name string, cleanup func()) {
if r := recover(); r != nil {
// Log the panic
ErrorPersist(fmt.Sprintf("Panic in %s: %v", name, r))
// Create a timestamped panic log file
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
file, err := os.Create(filename)
if err != nil {
ErrorPersist(fmt.Sprintf("Failed to create panic log: %v", err))
} else {
defer file.Close()
// Write panic information and stack trace
fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(file, "Stack Trace:\n%s\n", debug.Stack())
InfoPersist(fmt.Sprintf("Panic details written to %s", filename))
}
// Execute cleanup function if provided
if cleanup != nil {
cleanup()
}
}
}
-292
View File
@@ -1,292 +0,0 @@
package logging
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"runtime/debug"
"strings"
"time"
"github.com/go-logfmt/logfmt"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
)
type Log struct {
ID string
SessionID string
Timestamp time.Time
Level string
Message string
Attributes map[string]string
CreatedAt time.Time
}
const (
EventLogCreated pubsub.EventType = "log_created"
)
type Service interface {
pubsub.Subscriber[Log]
Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error
ListBySession(ctx context.Context, sessionID string) ([]Log, error)
ListAll(ctx context.Context, limit int) ([]Log, error)
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Log]
}
var globalLoggingService *service
func InitService(dbConn *sql.DB) error {
if globalLoggingService != nil {
return fmt.Errorf("logging service already initialized")
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Log]()
globalLoggingService = &service{
db: queries,
broker: broker,
}
return nil
}
func GetService() Service {
if globalLoggingService == nil {
panic("logging service not initialized. Call logging.InitService() first.")
}
return globalLoggingService
}
func (s *service) Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
if level == "" {
level = "info"
}
var attributesJSON sql.NullString
if len(attributes) > 0 {
attributesBytes, err := json.Marshal(attributes)
if err != nil {
return fmt.Errorf("failed to marshal log attributes: %w", err)
}
attributesJSON = sql.NullString{String: string(attributesBytes), Valid: true}
}
dbLog, err := s.db.CreateLog(ctx, db.CreateLogParams{
ID: uuid.New().String(),
SessionID: sql.NullString{String: sessionID, Valid: sessionID != ""},
Timestamp: timestamp.UTC().Format(time.RFC3339Nano),
Level: level,
Message: message,
Attributes: attributesJSON,
})
if err != nil {
return fmt.Errorf("db.CreateLog: %w", err)
}
log := s.fromDBItem(dbLog)
s.broker.Publish(EventLogCreated, log)
return nil
}
func (s *service) ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
dbLogs, err := s.db.ListLogsBySession(ctx, sql.NullString{String: sessionID, Valid: true})
if err != nil {
return nil, fmt.Errorf("db.ListLogsBySession: %w", err)
}
logs := make([]Log, len(dbLogs))
for i, dbSess := range dbLogs {
logs[i] = s.fromDBItem(dbSess)
}
return logs, nil
}
func (s *service) ListAll(ctx context.Context, limit int) ([]Log, error) {
dbLogs, err := s.db.ListAllLogs(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("db.ListAllLogs: %w", err)
}
logs := make([]Log, len(dbLogs))
for i, dbSess := range dbLogs {
logs[i] = s.fromDBItem(dbSess)
}
return logs, nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.Log) Log {
log := Log{
ID: item.ID,
SessionID: item.SessionID.String,
Level: item.Level,
Message: item.Message,
}
// Parse timestamp from ISO string
timestamp, err := time.Parse(time.RFC3339Nano, item.Timestamp)
if err == nil {
log.Timestamp = timestamp
} else {
log.Timestamp = time.Now() // Fallback
}
// Parse created_at from ISO string
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err == nil {
log.CreatedAt = createdAt
} else {
log.CreatedAt = time.Now() // Fallback
}
if item.Attributes.Valid && item.Attributes.String != "" {
if err := json.Unmarshal([]byte(item.Attributes.String), &log.Attributes); err != nil {
slog.Error("Failed to unmarshal log attributes", "log_id", item.ID, "error", err)
log.Attributes = make(map[string]string)
}
} else {
log.Attributes = make(map[string]string)
}
return log
}
func Create(ctx context.Context, timestamp time.Time, level, message string, attributes map[string]string, sessionID string) error {
return GetService().Create(ctx, timestamp, level, message, attributes, sessionID)
}
func ListBySession(ctx context.Context, sessionID string) ([]Log, error) {
return GetService().ListBySession(ctx, sessionID)
}
func ListAll(ctx context.Context, limit int) ([]Log, error) {
return GetService().ListAll(ctx, limit)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Log] {
return GetService().Subscribe(ctx)
}
type slogWriter struct{}
func (sw *slogWriter) Write(p []byte) (n int, err error) {
// Example: time=2024-05-09T12:34:56.789-05:00 level=INFO msg="User request" session=xyz foo=bar
d := logfmt.NewDecoder(bytes.NewReader(p))
for d.ScanRecord() {
var timestamp time.Time
var level string
var message string
var sessionID string
var attributes map[string]string
attributes = make(map[string]string)
hasTimestamp := false
for d.ScanKeyval() {
key := string(d.Key())
value := string(d.Value())
switch key {
case "time":
parsedTime, timeErr := time.Parse(time.RFC3339Nano, value)
if timeErr != nil {
parsedTime, timeErr = time.Parse(time.RFC3339, value)
if timeErr != nil {
slog.Error("Failed to parse time in slog writer", "value", value, "error", timeErr)
timestamp = time.Now().UTC()
hasTimestamp = true
continue
}
}
timestamp = parsedTime
hasTimestamp = true
case "level":
level = strings.ToLower(value)
case "msg", "message":
message = value
case "session_id":
sessionID = value
default:
attributes[key] = value
}
}
if d.Err() != nil {
return len(p), fmt.Errorf("logfmt.ScanRecord: %w", d.Err())
}
if !hasTimestamp {
timestamp = time.Now()
}
// Create log entry via the service (non-blocking or handle error appropriately)
// Using context.Background() as this is a low-level logging write.
go func(timestamp time.Time, level, message string, attributes map[string]string, sessionID string) { // Run in a goroutine to avoid blocking slog
if globalLoggingService == nil {
// If the logging service is not initialized, log the message to stderr
// fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: logging service not initialized\n")
return
}
if err := Create(context.Background(), timestamp, level, message, attributes, sessionID); err != nil {
// Log internal error using a more primitive logger to avoid loops
fmt.Fprintf(os.Stderr, "ERROR [logging.slogWriter]: failed to persist log: %v\n", err)
}
}(timestamp, level, message, attributes, sessionID)
}
if d.Err() != nil {
return len(p), fmt.Errorf("logfmt.ScanRecord final: %w", d.Err())
}
return len(p), nil
}
func NewSlogWriter() io.Writer {
return &slogWriter{}
}
// RecoverPanic is a common function to handle panics gracefully.
// It logs the error, creates a panic log file with stack trace,
// and executes an optional cleanup function.
func RecoverPanic(name string, cleanup func()) {
if r := recover(); r != nil {
errorMsg := fmt.Sprintf("Panic in %s: %v", name, r)
// Use slog directly here, as our service might be the one panicking.
slog.Error(errorMsg)
// status.Error(errorMsg)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("opencode-panic-%s-%s.log", name, timestamp)
file, err := os.Create(filename)
if err != nil {
errMsg := fmt.Sprintf("Failed to create panic log file '%s': %v", filename, err)
slog.Error(errMsg)
// status.Error(errMsg)
} else {
defer file.Close()
fmt.Fprintf(file, "Panic in %s: %v\n\n", name, r)
fmt.Fprintf(file, "Time: %s\n\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(file, "Stack Trace:\n%s\n", string(debug.Stack())) // Capture stack trace
infoMsg := fmt.Sprintf("Panic details written to %s", filename)
slog.Info(infoMsg)
// status.Info(infoMsg)
}
if cleanup != nil {
cleanup()
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package logging
import (
"time"
)
// LogMessage is the event payload for a log message
type LogMessage struct {
ID string
Time time.Time
Level string
Persist bool // used when we want to show the mesage in the status bar
PersistTime time.Duration // used when we want to show the mesage in the status bar
Message string `json:"msg"`
Attributes []Attr
}
type Attr struct {
Key string
Value string
}
+101
View File
@@ -0,0 +1,101 @@
package logging
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/go-logfmt/logfmt"
"github.com/opencode-ai/opencode/internal/pubsub"
)
const (
persistKeyArg = "$_persist"
PersistTimeArg = "$_persist_time"
)
type LogData struct {
messages []LogMessage
*pubsub.Broker[LogMessage]
lock sync.Mutex
}
func (l *LogData) Add(msg LogMessage) {
l.lock.Lock()
defer l.lock.Unlock()
l.messages = append(l.messages, msg)
l.Publish(pubsub.CreatedEvent, msg)
}
func (l *LogData) List() []LogMessage {
l.lock.Lock()
defer l.lock.Unlock()
return l.messages
}
var defaultLogData = &LogData{
messages: make([]LogMessage, 0),
Broker: pubsub.NewBroker[LogMessage](),
}
type writer struct{}
func (w *writer) Write(p []byte) (int, error) {
d := logfmt.NewDecoder(bytes.NewReader(p))
for d.ScanRecord() {
msg := LogMessage{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
Time: time.Now(),
}
for d.ScanKeyval() {
switch string(d.Key()) {
case "time":
parsed, err := time.Parse(time.RFC3339, string(d.Value()))
if err != nil {
return 0, fmt.Errorf("parsing time: %w", err)
}
msg.Time = parsed
case "level":
msg.Level = strings.ToLower(string(d.Value()))
case "msg":
msg.Message = string(d.Value())
default:
if string(d.Key()) == persistKeyArg {
msg.Persist = true
} else if string(d.Key()) == PersistTimeArg {
parsed, err := time.ParseDuration(string(d.Value()))
if err != nil {
continue
}
msg.PersistTime = parsed
} else {
msg.Attributes = append(msg.Attributes, Attr{
Key: string(d.Key()),
Value: string(d.Value()),
})
}
}
}
defaultLogData.Add(msg)
}
if d.Err() != nil {
return 0, d.Err()
}
return len(p), nil
}
func NewWriter() *writer {
w := &writer{}
return w
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
return defaultLogData.Subscribe(ctx)
}
func List() []LogMessage {
return defaultLogData.List()
}
+20 -32
View File
@@ -14,12 +14,9 @@ import (
"sync/atomic"
"time"
"log/slog"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/status"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
type Client struct {
@@ -99,17 +96,17 @@ func NewClient(ctx context.Context, command string, args ...string) (*Client, er
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
slog.Info("LSP Server", "message", scanner.Text())
fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
}
if err := scanner.Err(); err != nil {
slog.Error("Error reading LSP stderr", "error", err)
fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
}
}()
// Start message handling loop
go func() {
defer logging.RecoverPanic("LSP-message-handler", func() {
status.Error("LSP message handler crashed, LSP functionality may be impaired")
logging.ErrorPersist("LSP message handler crashed, LSP functionality may be impaired")
})
client.handleMessages()
}()
@@ -303,7 +300,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
defer ticker.Stop()
if cnf.DebugLSP {
slog.Debug("Waiting for LSP server to be ready...")
logging.Debug("Waiting for LSP server to be ready...")
}
// Determine server type for specialized initialization
@@ -312,7 +309,7 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
// For TypeScript-like servers, we need to open some key files first
if serverType == ServerTypeTypeScript {
if cnf.DebugLSP {
slog.Debug("TypeScript-like server detected, opening key configuration files")
logging.Debug("TypeScript-like server detected, opening key configuration files")
}
c.openKeyConfigFiles(ctx)
}
@@ -329,15 +326,15 @@ func (c *Client) WaitForServerReady(ctx context.Context) error {
// Server responded successfully
c.SetServerState(StateReady)
if cnf.DebugLSP {
slog.Debug("LSP server is ready")
logging.Debug("LSP server is ready")
}
return nil
} else {
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
}
if cnf.DebugLSP {
slog.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
logging.Debug("LSP server not ready yet", "error", err, "serverType", serverType)
}
}
}
@@ -412,9 +409,9 @@ func (c *Client) openKeyConfigFiles(ctx context.Context) {
if _, err := os.Stat(file); err == nil {
// File exists, try to open it
if err := c.OpenFile(ctx, file); err != nil {
slog.Debug("Failed to open key config file", "file", file, "error", err)
logging.Debug("Failed to open key config file", "file", file, "error", err)
} else {
slog.Debug("Opened key config file for initialization", "file", file)
logging.Debug("Opened key config file for initialization", "file", file)
}
}
}
@@ -490,7 +487,7 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
return nil
})
if err != nil {
slog.Debug("Error walking directory for TypeScript files", "error", err)
logging.Debug("Error walking directory for TypeScript files", "error", err)
}
// Final fallback - just try a generic capability
@@ -530,7 +527,7 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
if err := c.OpenFile(ctx, path); err == nil {
filesOpened++
if cnf.DebugLSP {
slog.Debug("Opened TypeScript file for initialization", "file", path)
logging.Debug("Opened TypeScript file for initialization", "file", path)
}
}
}
@@ -539,11 +536,11 @@ func (c *Client) openTypeScriptFiles(ctx context.Context, workDir string) {
})
if err != nil && cnf.DebugLSP {
slog.Debug("Error walking directory for TypeScript files", "error", err)
logging.Debug("Error walking directory for TypeScript files", "error", err)
}
if cnf.DebugLSP {
slog.Debug("Opened TypeScript files for initialization", "count", filesOpened)
logging.Debug("Opened TypeScript files for initialization", "count", filesOpened)
}
}
@@ -630,15 +627,6 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error {
func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)
// Verify file exists before attempting to read it
if _, err := os.Stat(filepath); err != nil {
if os.IsNotExist(err) {
// File was deleted - close it in the LSP client instead of notifying change
return c.CloseFile(ctx, filepath)
}
return fmt.Errorf("error checking file: %w", err)
}
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file: %w", err)
@@ -693,7 +681,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
}
if cnf.DebugLSP {
slog.Debug("Closing file", "file", filepath)
logging.Debug("Closing file", "file", filepath)
}
if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
return err
@@ -732,12 +720,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
for _, filePath := range filesToClose {
err := c.CloseFile(ctx, filePath)
if err != nil && cnf.DebugLSP {
slog.Warn("Error closing file", "file", filePath, "error", err)
logging.Warn("Error closing file", "file", filePath, "error", err)
}
}
if cnf.DebugLSP {
slog.Debug("Closed all files", "files", filesToClose)
logging.Debug("Closed all files", "files", filesToClose)
}
}
-65
View File
@@ -1,65 +0,0 @@
package discovery
import (
"fmt"
"github.com/sst/opencode/internal/config"
"log/slog"
)
// IntegrateLSPServers discovers languages and LSP servers and integrates them into the application configuration
func IntegrateLSPServers(workingDir string) error {
// Get the current configuration
cfg := config.Get()
if cfg == nil {
return fmt.Errorf("config not loaded")
}
// Check if this is the first run
shouldInit, err := config.ShouldShowInitDialog()
if err != nil {
return fmt.Errorf("failed to check initialization status: %w", err)
}
// Always run language detection, but log differently for first run vs. subsequent runs
if shouldInit || len(cfg.LSP) == 0 {
slog.Info("Running initial LSP auto-discovery...")
} else {
slog.Debug("Running LSP auto-discovery to detect new languages...")
}
// Configure LSP servers
servers, err := ConfigureLSPServers(workingDir)
if err != nil {
return fmt.Errorf("failed to configure LSP servers: %w", err)
}
// Update the configuration with discovered servers
for langID, serverInfo := range servers {
// Skip languages that already have a configured server
if _, exists := cfg.LSP[langID]; exists {
slog.Debug("LSP server already configured for language", "language", langID)
continue
}
if serverInfo.Available {
// Only add servers that were found
cfg.LSP[langID] = config.LSPConfig{
Disabled: false,
Command: serverInfo.Path,
Args: serverInfo.Args,
}
slog.Info("Added LSP server to configuration",
"language", langID,
"command", serverInfo.Command,
"path", serverInfo.Path)
} else {
slog.Warn("LSP server not available",
"language", langID,
"command", serverInfo.Command,
"installCmd", serverInfo.InstallCmd)
}
}
return nil
}
-298
View File
@@ -1,298 +0,0 @@
package discovery
import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/sst/opencode/internal/lsp"
"log/slog"
)
// LanguageInfo stores information about a detected language
type LanguageInfo struct {
// Language identifier (e.g., "go", "typescript", "python")
ID string
// Number of files detected for this language
FileCount int
// Project files associated with this language (e.g., go.mod, package.json)
ProjectFiles []string
// Whether this is likely a primary language in the project
IsPrimary bool
}
// ProjectFile represents a project configuration file
type ProjectFile struct {
// File name or pattern to match
Name string
// Associated language ID
LanguageID string
// Whether this file strongly indicates the language is primary
IsPrimary bool
}
// Common project files that indicate specific languages
var projectFilePatterns = []ProjectFile{
{Name: "go.mod", LanguageID: "go", IsPrimary: true},
{Name: "go.sum", LanguageID: "go", IsPrimary: false},
{Name: "package.json", LanguageID: "javascript", IsPrimary: true}, // Could be TypeScript too
{Name: "tsconfig.json", LanguageID: "typescript", IsPrimary: true},
{Name: "jsconfig.json", LanguageID: "javascript", IsPrimary: true},
{Name: "pyproject.toml", LanguageID: "python", IsPrimary: true},
{Name: "setup.py", LanguageID: "python", IsPrimary: true},
{Name: "requirements.txt", LanguageID: "python", IsPrimary: true},
{Name: "Cargo.toml", LanguageID: "rust", IsPrimary: true},
{Name: "Cargo.lock", LanguageID: "rust", IsPrimary: false},
{Name: "CMakeLists.txt", LanguageID: "cmake", IsPrimary: true},
{Name: "pom.xml", LanguageID: "java", IsPrimary: true},
{Name: "build.gradle", LanguageID: "java", IsPrimary: true},
{Name: "build.gradle.kts", LanguageID: "kotlin", IsPrimary: true},
{Name: "composer.json", LanguageID: "php", IsPrimary: true},
{Name: "Gemfile", LanguageID: "ruby", IsPrimary: true},
{Name: "Rakefile", LanguageID: "ruby", IsPrimary: true},
{Name: "mix.exs", LanguageID: "elixir", IsPrimary: true},
{Name: "rebar.config", LanguageID: "erlang", IsPrimary: true},
{Name: "dune-project", LanguageID: "ocaml", IsPrimary: true},
{Name: "stack.yaml", LanguageID: "haskell", IsPrimary: true},
{Name: "cabal.project", LanguageID: "haskell", IsPrimary: true},
{Name: "Makefile", LanguageID: "make", IsPrimary: false},
{Name: "Dockerfile", LanguageID: "dockerfile", IsPrimary: false},
}
// Map of file extensions to language IDs
var extensionToLanguage = map[string]string{
".go": "go",
".js": "javascript",
".jsx": "javascript",
".ts": "typescript",
".tsx": "typescript",
".py": "python",
".rs": "rust",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".rb": "ruby",
".php": "php",
".cs": "csharp",
".fs": "fsharp",
".swift": "swift",
".kt": "kotlin",
".scala": "scala",
".hs": "haskell",
".ml": "ocaml",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".lua": "lua",
".r": "r",
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".html": "html",
".css": "css",
".scss": "scss",
".sass": "sass",
".less": "less",
".json": "json",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".md": "markdown",
".dart": "dart",
}
// Directories to exclude from scanning
var excludedDirs = map[string]bool{
".git": true,
"node_modules": true,
"vendor": true,
"dist": true,
"build": true,
"target": true,
".idea": true,
".vscode": true,
".github": true,
".gitlab": true,
"__pycache__": true,
".next": true,
".nuxt": true,
"venv": true,
"env": true,
".env": true,
}
// DetectLanguages scans a directory to identify programming languages used in the project
func DetectLanguages(rootDir string) (map[string]LanguageInfo, error) {
languages := make(map[string]LanguageInfo)
var mutex sync.Mutex
// Walk the directory tree
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files that can't be accessed
}
// Skip excluded directories
if info.IsDir() {
if excludedDirs[info.Name()] || strings.HasPrefix(info.Name(), ".") {
return filepath.SkipDir
}
return nil
}
// Skip hidden files
if strings.HasPrefix(info.Name(), ".") {
return nil
}
// Check for project files
for _, pattern := range projectFilePatterns {
if info.Name() == pattern.Name {
mutex.Lock()
lang, exists := languages[pattern.LanguageID]
if !exists {
lang = LanguageInfo{
ID: pattern.LanguageID,
FileCount: 0,
ProjectFiles: []string{},
IsPrimary: pattern.IsPrimary,
}
}
lang.ProjectFiles = append(lang.ProjectFiles, path)
if pattern.IsPrimary {
lang.IsPrimary = true
}
languages[pattern.LanguageID] = lang
mutex.Unlock()
break
}
}
// Check file extension
ext := strings.ToLower(filepath.Ext(path))
if langID, ok := extensionToLanguage[ext]; ok {
mutex.Lock()
lang, exists := languages[langID]
if !exists {
lang = LanguageInfo{
ID: langID,
FileCount: 0,
ProjectFiles: []string{},
}
}
lang.FileCount++
languages[langID] = lang
mutex.Unlock()
}
return nil
})
if err != nil {
return nil, err
}
// Determine primary languages based on file count if not already marked
determinePrimaryLanguages(languages)
// Log detected languages
for id, info := range languages {
if info.IsPrimary {
slog.Debug("Detected primary language", "language", id, "files", info.FileCount, "projectFiles", len(info.ProjectFiles))
} else {
slog.Debug("Detected secondary language", "language", id, "files", info.FileCount)
}
}
return languages, nil
}
// determinePrimaryLanguages marks languages as primary based on file count
func determinePrimaryLanguages(languages map[string]LanguageInfo) {
// Find the language with the most files
var maxFiles int
for _, info := range languages {
if info.FileCount > maxFiles {
maxFiles = info.FileCount
}
}
// Mark languages with at least 20% of the max files as primary
threshold := max(maxFiles/5, 5) // At least 5 files to be considered primary
for id, info := range languages {
if !info.IsPrimary && info.FileCount >= threshold {
info.IsPrimary = true
languages[id] = info
}
}
}
// GetLanguageIDFromExtension returns the language ID for a given file extension
func GetLanguageIDFromExtension(ext string) string {
ext = strings.ToLower(ext)
if langID, ok := extensionToLanguage[ext]; ok {
return langID
}
return ""
}
// GetLanguageIDFromProtocol converts a protocol.LanguageKind to our language ID string
func GetLanguageIDFromProtocol(langKind string) string {
// Convert protocol language kind to our language ID
switch langKind {
case "go":
return "go"
case "typescript":
return "typescript"
case "typescriptreact":
return "typescript"
case "javascript":
return "javascript"
case "javascriptreact":
return "javascript"
case "python":
return "python"
case "rust":
return "rust"
case "java":
return "java"
case "c":
return "c"
case "cpp":
return "cpp"
default:
// Try to normalize the language kind
return strings.ToLower(langKind)
}
}
// GetLanguageIDFromPath determines the language ID from a file path
func GetLanguageIDFromPath(path string) string {
// Check file extension first
ext := filepath.Ext(path)
if langID := GetLanguageIDFromExtension(ext); langID != "" {
return langID
}
// Check if it's a known project file
filename := filepath.Base(path)
for _, pattern := range projectFilePatterns {
if filename == pattern.Name {
return pattern.LanguageID
}
}
// Use LSP's detection as a fallback
uri := "file://" + path
langKind := lsp.DetectLanguageID(uri)
return GetLanguageIDFromProtocol(string(langKind))
}
-306
View File
@@ -1,306 +0,0 @@
package discovery
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"log/slog"
)
// ServerInfo contains information about an LSP server
type ServerInfo struct {
// Command to run the server
Command string
// Arguments to pass to the command
Args []string
// Command to install the server (for user guidance)
InstallCmd string
// Whether this server is available
Available bool
// Full path to the executable (if found)
Path string
}
// LanguageServerMap maps language IDs to their corresponding LSP servers
var LanguageServerMap = map[string]ServerInfo{
"go": {
Command: "gopls",
InstallCmd: "go install golang.org/x/tools/gopls@latest",
},
"typescript": {
Command: "typescript-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g typescript-language-server typescript",
},
"javascript": {
Command: "typescript-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g typescript-language-server typescript",
},
"python": {
Command: "pylsp",
InstallCmd: "pip install python-lsp-server",
},
"rust": {
Command: "rust-analyzer",
InstallCmd: "rustup component add rust-analyzer",
},
"java": {
Command: "jdtls",
InstallCmd: "Install Eclipse JDT Language Server",
},
"c": {
Command: "clangd",
InstallCmd: "Install clangd from your package manager",
},
"cpp": {
Command: "clangd",
InstallCmd: "Install clangd from your package manager",
},
"php": {
Command: "intelephense",
Args: []string{"--stdio"},
InstallCmd: "npm install -g intelephense",
},
"ruby": {
Command: "solargraph",
Args: []string{"stdio"},
InstallCmd: "gem install solargraph",
},
"lua": {
Command: "lua-language-server",
InstallCmd: "Install lua-language-server from your package manager",
},
"html": {
Command: "vscode-html-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"css": {
Command: "vscode-css-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"json": {
Command: "vscode-json-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g vscode-langservers-extracted",
},
"yaml": {
Command: "yaml-language-server",
Args: []string{"--stdio"},
InstallCmd: "npm install -g yaml-language-server",
},
}
// FindLSPServer searches for an LSP server for the given language
func FindLSPServer(languageID string) (ServerInfo, error) {
// Get server info for the language
serverInfo, exists := LanguageServerMap[languageID]
if !exists {
return ServerInfo{}, fmt.Errorf("no LSP server defined for language: %s", languageID)
}
// Check if the command is in PATH
path, err := exec.LookPath(serverInfo.Command)
if err == nil {
serverInfo.Available = true
serverInfo.Path = path
slog.Debug("Found LSP server in PATH", "language", languageID, "command", serverInfo.Command, "path", path)
return serverInfo, nil
}
// If not in PATH, search in common installation locations
paths := getCommonLSPPaths(languageID, serverInfo.Command)
for _, searchPath := range paths {
if _, err := os.Stat(searchPath); err == nil {
// Found the server
serverInfo.Available = true
serverInfo.Path = searchPath
slog.Debug("Found LSP server in common location", "language", languageID, "command", serverInfo.Command, "path", searchPath)
return serverInfo, nil
}
}
// Server not found
slog.Debug("LSP server not found", "language", languageID, "command", serverInfo.Command)
return serverInfo, fmt.Errorf("LSP server for %s not found. Install with: %s", languageID, serverInfo.InstallCmd)
}
// getCommonLSPPaths returns common installation paths for LSP servers based on language and OS
func getCommonLSPPaths(languageID, command string) []string {
var paths []string
homeDir, err := os.UserHomeDir()
if err != nil {
slog.Error("Failed to get user home directory", "error", err)
return paths
}
// Add platform-specific paths
switch runtime.GOOS {
case "darwin":
// macOS paths
paths = append(paths,
fmt.Sprintf("/usr/local/bin/%s", command),
fmt.Sprintf("/opt/homebrew/bin/%s", command),
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
)
case "linux":
// Linux paths
paths = append(paths,
fmt.Sprintf("/usr/bin/%s", command),
fmt.Sprintf("/usr/local/bin/%s", command),
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
)
case "windows":
// Windows paths
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Local\\Programs\\%s.exe", homeDir, command),
fmt.Sprintf("C:\\Program Files\\%s\\bin\\%s.exe", command, command),
)
}
// Add language-specific paths
switch languageID {
case "go":
gopath := os.Getenv("GOPATH")
if gopath == "" {
gopath = filepath.Join(homeDir, "go")
}
paths = append(paths, filepath.Join(gopath, "bin", command))
if runtime.GOOS == "windows" {
paths = append(paths, filepath.Join(gopath, "bin", command+".exe"))
}
case "typescript", "javascript", "html", "css", "json", "yaml", "php":
// Node.js global packages
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\%s.cmd", homeDir, command),
fmt.Sprintf("%s\\AppData\\Roaming\\npm\\node_modules\\.bin\\%s.cmd", homeDir, command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.npm-global/bin/%s", homeDir, command),
fmt.Sprintf("%s/.nvm/versions/node/*/bin/%s", homeDir, command),
fmt.Sprintf("/usr/local/lib/node_modules/.bin/%s", command),
)
}
case "python":
// Python paths
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\AppData\\Local\\Programs\\Python\\Python*\\Scripts\\%s.exe", homeDir, command),
fmt.Sprintf("C:\\Python*\\Scripts\\%s.exe", command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.local/bin/%s", homeDir, command),
fmt.Sprintf("%s/.pyenv/shims/%s", homeDir, command),
fmt.Sprintf("/usr/local/bin/%s", command),
)
}
case "rust":
// Rust paths
if runtime.GOOS == "windows" {
paths = append(paths,
fmt.Sprintf("%s\\.rustup\\toolchains\\*\\bin\\%s.exe", homeDir, command),
fmt.Sprintf("%s\\.cargo\\bin\\%s.exe", homeDir, command),
)
} else {
paths = append(paths,
fmt.Sprintf("%s/.rustup/toolchains/*/bin/%s", homeDir, command),
fmt.Sprintf("%s/.cargo/bin/%s", homeDir, command),
)
}
}
// Add VSCode extensions path
vscodePath := getVSCodeExtensionsPath(homeDir)
if vscodePath != "" {
paths = append(paths, vscodePath)
}
// Expand any glob patterns in paths
var expandedPaths []string
for _, path := range paths {
if strings.Contains(path, "*") {
// This is a glob pattern, expand it
matches, err := filepath.Glob(path)
if err == nil {
expandedPaths = append(expandedPaths, matches...)
}
} else {
expandedPaths = append(expandedPaths, path)
}
}
return expandedPaths
}
// getVSCodeExtensionsPath returns the path to VSCode extensions directory
func getVSCodeExtensionsPath(homeDir string) string {
var basePath string
switch runtime.GOOS {
case "darwin":
basePath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage")
case "linux":
basePath = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage")
case "windows":
basePath = filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "globalStorage")
default:
return ""
}
// Check if the directory exists
if _, err := os.Stat(basePath); err != nil {
return ""
}
return basePath
}
// ConfigureLSPServers detects languages and configures LSP servers
func ConfigureLSPServers(rootDir string) (map[string]ServerInfo, error) {
// Detect languages in the project
languages, err := DetectLanguages(rootDir)
if err != nil {
return nil, fmt.Errorf("failed to detect languages: %w", err)
}
// Find LSP servers for detected languages
servers := make(map[string]ServerInfo)
for langID, langInfo := range languages {
// Prioritize primary languages but include all languages that have server definitions
if !langInfo.IsPrimary && langInfo.FileCount < 3 {
// Skip non-primary languages with very few files
slog.Debug("Skipping non-primary language with few files", "language", langID, "files", langInfo.FileCount)
continue
}
// Check if we have a server for this language
serverInfo, err := FindLSPServer(langID)
if err != nil {
slog.Warn("LSP server not found", "language", langID, "error", err)
continue
}
// Add to the map of configured servers
servers[langID] = serverInfo
if langInfo.IsPrimary {
slog.Info("Configured LSP server for primary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
} else {
slog.Info("Configured LSP server for secondary language", "language", langID, "command", serverInfo.Command, "path", serverInfo.Path)
}
}
return servers, nil
}
+10 -10
View File
@@ -3,10 +3,10 @@ package lsp
import (
"encoding/json"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/lsp/util"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/lsp/util"
)
// Requests
@@ -18,7 +18,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
func HandleRegisterCapability(params json.RawMessage) (any, error) {
var registerParams protocol.RegistrationParams
if err := json.Unmarshal(params, &registerParams); err != nil {
slog.Error("Error unmarshaling registration params", "error", err)
logging.Error("Error unmarshaling registration params", "error", err)
return nil, err
}
@@ -28,13 +28,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) {
// Parse the registration options
optionsJSON, err := json.Marshal(reg.RegisterOptions)
if err != nil {
slog.Error("Error marshaling registration options", "error", err)
logging.Error("Error marshaling registration options", "error", err)
continue
}
var options protocol.DidChangeWatchedFilesRegistrationOptions
if err := json.Unmarshal(optionsJSON, &options); err != nil {
slog.Error("Error unmarshaling registration options", "error", err)
logging.Error("Error unmarshaling registration options", "error", err)
continue
}
@@ -54,7 +54,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) {
err := util.ApplyWorkspaceEdit(edit.Edit)
if err != nil {
slog.Error("Error applying workspace edit", "error", err)
logging.Error("Error applying workspace edit", "error", err)
return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
}
@@ -89,7 +89,7 @@ func HandleServerMessage(params json.RawMessage) {
}
if err := json.Unmarshal(params, &msg); err == nil {
if cnf.DebugLSP {
slog.Debug("Server message", "type", msg.Type, "message", msg.Message)
logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
}
}
}
@@ -97,7 +97,7 @@ func HandleServerMessage(params json.RawMessage) {
func HandleDiagnostics(client *Client, params json.RawMessage) {
var diagParams protocol.PublishDiagnosticsParams
if err := json.Unmarshal(params, &diagParams); err != nil {
slog.Error("Error unmarshaling diagnostics params", "error", err)
logging.Error("Error unmarshaling diagnostics params", "error", err)
return
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"path/filepath"
"strings"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
func DetectLanguageID(uri string) protocol.LanguageKind {
+1 -1
View File
@@ -4,7 +4,7 @@ package lsp
import (
"context"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
// Implementation sends a textDocument/implementation request to the LSP server.
+17 -17
View File
@@ -8,8 +8,8 @@ import (
"io"
"strings"
"github.com/sst/opencode/internal/config"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
)
// Write writes an LSP message to the given writer
@@ -21,7 +21,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
}
_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
@@ -50,7 +50,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
line = strings.TrimSpace(line)
if cnf.DebugLSP {
slog.Debug("Received header", "line", line)
logging.Debug("Received header", "line", line)
}
if line == "" {
@@ -66,7 +66,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
}
if cnf.DebugLSP {
slog.Debug("Content-Length", "length", contentLength)
logging.Debug("Content-Length", "length", contentLength)
}
// Read content
@@ -77,7 +77,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
}
if cnf.DebugLSP {
slog.Debug("Received content", "content", string(content))
logging.Debug("Received content", "content", string(content))
}
// Parse message
@@ -96,7 +96,7 @@ func (c *Client) handleMessages() {
msg, err := ReadMessage(c.stdout)
if err != nil {
if cnf.DebugLSP {
slog.Error("Error reading message", "error", err)
logging.Error("Error reading message", "error", err)
}
return
}
@@ -104,7 +104,7 @@ func (c *Client) handleMessages() {
// Handle server->client request (has both Method and ID)
if msg.Method != "" && msg.ID != 0 {
if cnf.DebugLSP {
slog.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
}
response := &Message{
@@ -144,7 +144,7 @@ func (c *Client) handleMessages() {
// Send response back to server
if err := WriteMessage(c.stdin, response); err != nil {
slog.Error("Error sending response to server", "error", err)
logging.Error("Error sending response to server", "error", err)
}
continue
@@ -158,11 +158,11 @@ func (c *Client) handleMessages() {
if ok {
if cnf.DebugLSP {
slog.Debug("Handling notification", "method", msg.Method)
logging.Debug("Handling notification", "method", msg.Method)
}
go handler(msg.Params)
} else if cnf.DebugLSP {
slog.Debug("No handler for notification", "method", msg.Method)
logging.Debug("No handler for notification", "method", msg.Method)
}
continue
}
@@ -175,12 +175,12 @@ func (c *Client) handleMessages() {
if ok {
if cnf.DebugLSP {
slog.Debug("Received response for request", "id", msg.ID)
logging.Debug("Received response for request", "id", msg.ID)
}
ch <- msg
close(ch)
} else if cnf.DebugLSP {
slog.Debug("No handler for response", "id", msg.ID)
logging.Debug("No handler for response", "id", msg.ID)
}
}
}
@@ -192,7 +192,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
id := c.nextID.Add(1)
if cnf.DebugLSP {
slog.Debug("Making call", "method", method, "id", id)
logging.Debug("Making call", "method", method, "id", id)
}
msg, err := NewRequest(id, method, params)
@@ -218,14 +218,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
}
if cnf.DebugLSP {
slog.Debug("Request sent", "method", method, "id", id)
logging.Debug("Request sent", "method", method, "id", id)
}
// Wait for response
resp := <-ch
if cnf.DebugLSP {
slog.Debug("Received response", "id", id)
logging.Debug("Received response", "id", id)
}
if resp.Error != nil {
@@ -251,7 +251,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
func (c *Client) Notify(ctx context.Context, method string, params any) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Sending notification", "method", method)
logging.Debug("Sending notification", "method", method)
}
msg, err := NewNotification(method, params)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
+61 -123
View File
@@ -5,17 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/fsnotify/fsnotify"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"log/slog"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
)
// WorkspaceWatcher manages LSP file watching
@@ -46,7 +45,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
cnf := config.Get()
slog.Debug("Adding file watcher registrations")
logging.Debug("Adding file watcher registrations")
w.registrationMu.Lock()
defer w.registrationMu.Unlock()
@@ -55,33 +54,33 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
// Print detailed registration information for debugging
if cnf.DebugLSP {
slog.Debug("Adding file watcher registrations",
logging.Debug("Adding file watcher registrations",
"id", id,
"watchers", len(watchers),
"total", len(w.registrations),
)
for i, watcher := range watchers {
slog.Debug("Registration", "index", i+1)
logging.Debug("Registration", "index", i+1)
// Log the GlobPattern
switch v := watcher.GlobPattern.Value.(type) {
case string:
slog.Debug("GlobPattern", "pattern", v)
logging.Debug("GlobPattern", "pattern", v)
case protocol.RelativePattern:
slog.Debug("GlobPattern", "pattern", v.Pattern)
logging.Debug("GlobPattern", "pattern", v.Pattern)
// Log BaseURI details
switch u := v.BaseURI.Value.(type) {
case string:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
case protocol.DocumentUri:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
default:
slog.Debug("BaseURI", "baseURI", u)
logging.Debug("BaseURI", "baseURI", u)
}
default:
slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
}
// Log WatchKind
@@ -90,13 +89,13 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
watchKind = *watcher.Kind
}
slog.Debug("WatchKind", "kind", watchKind)
logging.Debug("WatchKind", "kind", watchKind)
}
}
// Determine server type for specialized handling
serverName := getServerNameFromContext(ctx)
slog.Debug("Server type detected", "serverName", serverName)
logging.Debug("Server type detected", "serverName", serverName)
// Check if this server has sent file watchers
hasFileWatchers := len(watchers) > 0
@@ -124,7 +123,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
filesOpened += highPriorityFilesOpened
if cnf.DebugLSP {
slog.Debug("Opened high-priority files",
logging.Debug("Opened high-priority files",
"count", highPriorityFilesOpened,
"serverName", serverName)
}
@@ -132,7 +131,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
// If we've already opened enough high-priority files, we might not need more
if filesOpened >= maxFilesToOpen {
if cnf.DebugLSP {
slog.Debug("Reached file limit with high-priority files",
logging.Debug("Reached file limit with high-priority files",
"filesOpened", filesOpened,
"maxFiles", maxFilesToOpen)
}
@@ -150,7 +149,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
if d.IsDir() {
if path != w.workspacePath && shouldExcludeDir(path) {
if cnf.DebugLSP {
slog.Debug("Skipping excluded directory", "path", path)
logging.Debug("Skipping excluded directory", "path", path)
}
return filepath.SkipDir
}
@@ -178,7 +177,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
elapsedTime := time.Since(startTime)
if cnf.DebugLSP {
slog.Debug("Limited workspace scan complete",
logging.Debug("Limited workspace scan complete",
"filesOpened", filesOpened,
"maxFiles", maxFilesToOpen,
"elapsedTime", elapsedTime.Seconds(),
@@ -187,11 +186,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
}
if err != nil && cnf.DebugLSP {
slog.Debug("Error scanning workspace for files to open", "error", err)
logging.Debug("Error scanning workspace for files to open", "error", err)
}
}()
} else if cnf.DebugLSP {
slog.Debug("Using on-demand file loading for server", "server", serverName)
logging.Debug("Using on-demand file loading for server", "server", serverName)
}
}
@@ -264,7 +263,7 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
matches, err := doublestar.Glob(os.DirFS(w.workspacePath), pattern)
if err != nil {
if cnf.DebugLSP {
slog.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
logging.Debug("Error finding high-priority files", "pattern", pattern, "error", err)
}
continue
}
@@ -282,12 +281,12 @@ func (w *WorkspaceWatcher) openHighPriorityFiles(ctx context.Context, serverName
// Open the file
if err := w.client.OpenFile(ctx, fullPath); err != nil {
if cnf.DebugLSP {
slog.Debug("Error opening high-priority file", "path", fullPath, "error", err)
logging.Debug("Error opening high-priority file", "path", fullPath, "error", err)
}
} else {
filesOpened++
if cnf.DebugLSP {
slog.Debug("Opened high-priority file", "path", fullPath)
logging.Debug("Opened high-priority file", "path", fullPath)
}
}
@@ -319,7 +318,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
}
serverName := getServerNameFromContext(ctx)
slog.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
logging.Debug("Starting workspace watcher", "workspacePath", workspacePath, "serverName", serverName)
// Register handler for file watcher registrations from the server
lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
@@ -328,7 +327,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
watcher, err := fsnotify.NewWatcher()
if err != nil {
slog.Error("Error creating watcher", "error", err)
logging.Error("Error creating watcher", "error", err)
}
defer watcher.Close()
@@ -342,7 +341,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if d.IsDir() && path != workspacePath {
if shouldExcludeDir(path) {
if cnf.DebugLSP {
slog.Debug("Skipping excluded directory", "path", path)
logging.Debug("Skipping excluded directory", "path", path)
}
return filepath.SkipDir
}
@@ -352,14 +351,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if d.IsDir() {
err = watcher.Add(path)
if err != nil {
slog.Error("Error watching path", "path", path, "error", err)
logging.Error("Error watching path", "path", path, "error", err)
}
}
return nil
})
if err != nil {
slog.Error("Error walking workspace", "error", err)
logging.Error("Error walking workspace", "error", err)
}
// Event loop
@@ -376,29 +375,19 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Add new directories to the watcher
if event.Op&fsnotify.Create != 0 {
// Check if the file/directory still exists before processing
info, err := os.Stat(event.Name)
if err != nil {
if os.IsNotExist(err) {
// File was deleted between event and processing - ignore
slog.Debug("File deleted between create event and stat", "path", event.Name)
continue
}
slog.Error("Error getting file info", "path", event.Name, "error", err)
continue
}
if info.IsDir() {
// Skip excluded directories
if !shouldExcludeDir(event.Name) {
if err := watcher.Add(event.Name); err != nil {
slog.Error("Error adding directory to watcher", "path", event.Name, "error", err)
if info, err := os.Stat(event.Name); err == nil {
if info.IsDir() {
// Skip excluded directories
if !shouldExcludeDir(event.Name) {
if err := watcher.Add(event.Name); err != nil {
logging.Error("Error adding directory to watcher", "path", event.Name, "error", err)
}
}
} else {
// For newly created files
if !shouldExcludeFile(event.Name) {
w.openMatchingFile(ctx, event.Name)
}
}
} else {
// For newly created files
if !shouldExcludeFile(event.Name) {
w.openMatchingFile(ctx, event.Name)
}
}
}
@@ -406,7 +395,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Debug logging
if cnf.DebugLSP {
matched, kind := w.isPathWatched(event.Name)
slog.Debug("File event",
logging.Debug("File event",
"path", event.Name,
"operation", event.Op.String(),
"watched", matched,
@@ -427,7 +416,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
// Just send the notification if needed
info, err := os.Stat(event.Name)
if err != nil {
slog.Error("Error getting file info", "path", event.Name, "error", err)
logging.Error("Error getting file info", "path", event.Name, "error", err)
return
}
if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
@@ -455,7 +444,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
if !ok {
return
}
slog.Error("Error watching file", "error", err)
logging.Error("Error watching file", "error", err)
}
}
}
@@ -580,7 +569,7 @@ func matchesSimpleGlob(pattern, path string) bool {
// Fall back to simple matching for simpler patterns
matched, err := filepath.Match(pattern, path)
if err != nil {
slog.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
return false
}
@@ -591,7 +580,7 @@ func matchesSimpleGlob(pattern, path string) bool {
func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
patternInfo, err := pattern.AsPattern()
if err != nil {
slog.Error("Error parsing pattern", "pattern", pattern, "error", err)
logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
return false
}
@@ -616,7 +605,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
// Make path relative to basePath for matching
relPath, err := filepath.Rel(basePath, path)
if err != nil {
slog.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
return false
}
relPath = filepath.ToSlash(relPath)
@@ -654,55 +643,19 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri
func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
// If the file is open and it's a change event, use didChange notification
filePath := uri[7:] // Remove "file://" prefix
if changeType == protocol.FileChangeType(protocol.Deleted) {
// Always clear diagnostics for deleted files
w.client.ClearDiagnosticsForURI(protocol.DocumentUri(uri))
// If the file was open, close it in the LSP client
if w.client.IsFileOpen(filePath) {
if err := w.client.CloseFile(ctx, filePath); err != nil {
slog.Debug("Error closing deleted file in LSP client", "file", filePath, "error", err)
// Continue anyway - the file is gone
}
}
} else if changeType == protocol.FileChangeType(protocol.Changed) {
// For changed files, verify the file still exists before notifying
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
// File was deleted between the event and now - treat as delete
slog.Debug("File deleted between change event and processing", "file", filePath)
w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
return
}
slog.Error("Error getting file info", "path", filePath, "error", err)
return
}
// File exists and is open, notify change
if w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
slog.Error("Error notifying change", "error", err)
}
return
}
} else if changeType == protocol.FileChangeType(protocol.Created) {
// For created files, verify the file still exists before notifying
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
// File was deleted between the event and now - ignore
slog.Debug("File deleted between create event and processing", "file", filePath)
return
}
slog.Error("Error getting file info", "path", filePath, "error", err)
return
} else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
logging.Error("Error notifying change", "error", err)
}
return
}
// Notify LSP server about the file event using didChangeWatchedFiles
if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
slog.Error("Error notifying LSP server about file event", "error", err)
logging.Error("Error notifying LSP server about file event", "error", err)
}
}
@@ -710,7 +663,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
cnf := config.Get()
if cnf.DebugLSP {
slog.Debug("Notifying file event",
logging.Debug("Notifying file event",
"uri", uri,
"changeType", changeType,
)
@@ -875,11 +828,6 @@ func shouldExcludeFile(filePath string) bool {
return true
}
// Skip numeric temporary files (often created by editors)
if _, err := strconv.Atoi(fileName); err == nil {
return true
}
// Check file size
info, err := os.Stat(filePath)
if err != nil {
@@ -890,7 +838,7 @@ func shouldExcludeFile(filePath string) bool {
// Skip large files
if info.Size() > maxFileSize {
if cnf.DebugLSP {
slog.Debug("Skipping large file",
logging.Debug("Skipping large file",
"path", filePath,
"size", info.Size(),
"maxSize", maxFileSize,
@@ -908,19 +856,9 @@ func shouldExcludeFile(filePath string) bool {
// openMatchingFile opens a file if it matches any of the registered patterns
func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
cnf := config.Get()
// Skip directories and verify file exists
// Skip directories
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// File was deleted between event and processing - ignore
slog.Debug("File deleted between event and openMatchingFile", "path", path)
return
}
slog.Error("Error getting file info", "path", path, "error", err)
return
}
if info.IsDir() {
if err != nil || info.IsDir() {
return
}
@@ -938,10 +876,10 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
// This helps with project initialization for certain language servers
if isHighPriorityFile(path, serverName) {
if cnf.DebugLSP {
slog.Debug("Opening high-priority file", "path", path, "serverName", serverName)
logging.Debug("Opening high-priority file", "path", path, "serverName", serverName)
}
if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
slog.Error("Error opening high-priority file", "path", path, "error", err)
logging.Error("Error opening high-priority file", "path", path, "error", err)
}
return
}
@@ -953,7 +891,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
// Check file size - for preloading we're more conservative
if info.Size() > (1 * 1024 * 1024) { // 1MB limit for preloaded files
if cnf.DebugLSP {
slog.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
logging.Debug("Skipping large file for preloading", "path", path, "size", info.Size())
}
return
}
@@ -985,7 +923,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
if shouldOpen {
// Don't need to check if it's already open - the client.OpenFile handles that
if err := w.client.OpenFile(ctx, path); err != nil && cnf.DebugLSP {
slog.Error("Error opening file", "path", path, "error", err)
logging.Error("Error opening file", "path", path, "error", err)
}
}
}
+16 -14
View File
@@ -5,7 +5,7 @@ import (
"slices"
"time"
"github.com/sst/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/models"
)
type MessageRole string
@@ -48,10 +48,7 @@ type TextContent struct {
Text string `json:"text"`
}
func (tc *TextContent) String() string {
if tc == nil {
return ""
}
func (tc TextContent) String() string {
return tc.Text
}
@@ -105,24 +102,29 @@ type ToolResult struct {
func (ToolResult) isPart() {}
type Finish struct {
Reason FinishReason `json:"reason"`
Time time.Time `json:"time"`
}
type DBFinish struct {
Reason FinishReason `json:"reason"`
Time int64 `json:"time"`
}
func (Finish) isPart() {}
func (m *Message) Content() *TextContent {
type Message struct {
ID string
Role MessageRole
SessionID string
Parts []ContentPart
Model models.ModelID
CreatedAt int64
UpdatedAt int64
}
func (m *Message) Content() TextContent {
for _, part := range m.Parts {
if c, ok := part.(TextContent); ok {
return &c
return c
}
}
return nil
return TextContent{}
}
func (m *Message) ReasoningContent() ReasoningContent {
@@ -313,7 +315,7 @@ func (m *Message) AddFinish(reason FinishReason) {
break
}
}
m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now()})
m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
}
func (m *Message) AddImageURL(url, detail string) {
+127 -349
View File
@@ -5,31 +5,12 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/pubsub"
)
type Message struct {
ID string
Role MessageRole
SessionID string
Parts []ContentPart
Model models.ModelID
CreatedAt time.Time
UpdatedAt time.Time
}
const (
EventMessageCreated pubsub.EventType = "message_created"
EventMessageUpdated pubsub.EventType = "message_updated"
EventMessageDeleted pubsub.EventType = "message_deleted"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/pubsub"
)
type CreateMessageParams struct {
@@ -39,317 +20,144 @@ type CreateMessageParams struct {
}
type Service interface {
pubsub.Subscriber[Message]
pubsub.Suscriber[Message]
Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error)
Update(ctx context.Context, message Message) (Message, error)
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 time.Time) ([]Message, error)
Delete(ctx context.Context, id string) error
DeleteSessionMessages(ctx context.Context, sessionID string) error
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Message]
mu sync.RWMutex
*pubsub.Broker[Message]
q db.Querier
}
var globalMessageService *service
func InitService(dbConn *sql.DB) error {
if globalMessageService != nil {
return fmt.Errorf("message service already initialized")
func NewService(q db.Querier) Service {
return &service{
Broker: pubsub.NewBroker[Message](),
q: q,
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Message]()
}
globalMessageService = &service{
db: queries,
broker: broker,
func (s *service) Delete(ctx context.Context, id string) error {
message, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteMessage(ctx, message.ID)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, message)
return nil
}
func GetService() Service {
if globalMessageService == nil {
panic("message service not initialized. Call message.InitService() first.")
}
return globalMessageService
}
func (s *service) Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
isFinished := false
for _, p := range params.Parts {
if _, ok := p.(Finish); ok {
isFinished = true
break
}
if params.Role != Assistant {
params.Parts = append(params.Parts, Finish{
Reason: "stop",
})
}
if params.Role == User && !isFinished {
params.Parts = append(params.Parts, Finish{Reason: FinishReasonEndTurn, Time: time.Now()})
}
partsJSON, err := marshallParts(params.Parts)
if err != nil {
return Message{}, fmt.Errorf("failed to marshal message parts: %w", err)
return Message{}, err
}
dbMsgParams := db.CreateMessageParams{
dbMessage, err := s.q.CreateMessage(ctx, db.CreateMessageParams{
ID: uuid.New().String(),
SessionID: sessionID,
Role: string(params.Role),
Parts: string(partsJSON),
Model: sql.NullString{String: string(params.Model), Valid: params.Model != ""},
}
dbMessage, err := s.db.CreateMessage(ctx, dbMsgParams)
Model: sql.NullString{String: string(params.Model), Valid: true},
})
if err != nil {
return Message{}, fmt.Errorf("db.CreateMessage: %w", err)
return Message{}, err
}
message, err := s.fromDBItem(dbMessage)
if err != nil {
return Message{}, fmt.Errorf("failed to convert DB message: %w", err)
return Message{}, err
}
s.broker.Publish(EventMessageCreated, message)
s.Publish(pubsub.CreatedEvent, message)
return message, nil
}
func (s *service) Update(ctx context.Context, message Message) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if message.ID == "" {
return Message{}, fmt.Errorf("cannot update message with empty ID")
}
partsJSON, err := marshallParts(message.Parts)
func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
messages, err := s.List(ctx, sessionID)
if err != nil {
return Message{}, fmt.Errorf("failed to marshal message parts for update: %w", err)
return err
}
var dbFinishedAt sql.NullString
finishPart := message.FinishPart()
if finishPart != nil && !finishPart.Time.IsZero() {
dbFinishedAt = sql.NullString{
String: finishPart.Time.UTC().Format(time.RFC3339Nano),
Valid: true,
for _, message := range messages {
if message.SessionID == sessionID {
err = s.Delete(ctx, message.ID)
if err != nil {
return err
}
}
}
return nil
}
// UpdatedAt is handled by the DB trigger (strftime('%s', 'now'))
err = s.db.UpdateMessage(ctx, db.UpdateMessageParams{
func (s *service) Update(ctx context.Context, message Message) error {
parts, err := marshallParts(message.Parts)
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(ctx, db.UpdateMessageParams{
ID: message.ID,
Parts: string(partsJSON),
FinishedAt: dbFinishedAt,
Parts: string(parts),
FinishedAt: finishedAt,
})
if err != nil {
return Message{}, fmt.Errorf("db.UpdateMessage: %w", err)
return err
}
dbUpdatedMessage, err := s.db.GetMessage(ctx, message.ID)
if err != nil {
return Message{}, fmt.Errorf("failed to fetch message after update: %w", err)
}
updatedMessage, err := s.fromDBItem(dbUpdatedMessage)
if err != nil {
return Message{}, fmt.Errorf("failed to convert updated DB message: %w", err)
}
s.broker.Publish(EventMessageUpdated, updatedMessage)
return updatedMessage, nil
message.UpdatedAt = time.Now().Unix()
s.Publish(pubsub.UpdatedEvent, message)
return nil
}
func (s *service) Get(ctx context.Context, id string) (Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessage, err := s.db.GetMessage(ctx, id)
dbMessage, err := s.q.GetMessage(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return Message{}, fmt.Errorf("message with ID '%s' not found", id)
}
return Message{}, fmt.Errorf("db.GetMessage: %w", err)
return Message{}, err
}
return s.fromDBItem(dbMessage)
}
func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessages, err := s.db.ListMessagesBySession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("db.ListMessagesBySession: %w", err)
}
messages := make([]Message, len(dbMessages))
for i, dbMsg := range dbMessages {
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message at index %d: %w", i, convErr)
}
messages[i] = msg
}
return messages, nil
}
func (s *service) ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbMessages, err := s.db.ListMessagesBySessionAfter(ctx, db.ListMessagesBySessionAfterParams{
SessionID: sessionID,
CreatedAt: timestamp.Format(time.RFC3339Nano),
})
if err != nil {
return nil, fmt.Errorf("db.ListMessagesBySessionAfter: %w", err)
}
messages := make([]Message, len(dbMessages))
for i, dbMsg := range dbMessages {
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message at index %d (ListAfter): %w", i, convErr)
}
messages[i] = msg
}
return messages, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
messageToPublish, err := s.getServiceForPublish(ctx, id)
s.mu.Unlock()
if err != nil {
// If error was due to not found, it's not a critical failure for deletion intent
if strings.Contains(err.Error(), "not found") {
return nil // Or return the error if strictness is required
}
return err
}
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteMessage(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteMessage: %w", err)
}
if messageToPublish != nil {
s.broker.Publish(EventMessageDeleted, *messageToPublish)
}
return nil
}
func (s *service) getServiceForPublish(ctx context.Context, id string) (*Message, error) {
dbMsg, err := s.db.GetMessage(ctx, id)
dbMessages, err := s.q.ListMessagesBySession(ctx, sessionID)
if err != nil {
return nil, err
}
msg, convErr := s.fromDBItem(dbMsg)
if convErr != nil {
return nil, fmt.Errorf("failed to convert DB message for publishing: %w", convErr)
}
return &msg, nil
}
func (s *service) DeleteSessionMessages(ctx context.Context, sessionID string) error {
s.mu.Lock()
defer s.mu.Unlock()
messagesToDelete, err := s.db.ListMessagesBySession(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to list messages for deletion: %w", err)
}
err = s.db.DeleteSessionMessages(ctx, sessionID)
if err != nil {
return fmt.Errorf("db.DeleteSessionMessages: %w", err)
}
for _, dbMsg := range messagesToDelete {
msg, convErr := s.fromDBItem(dbMsg)
if convErr == nil {
s.broker.Publish(EventMessageDeleted, msg)
} else {
slog.Error("Failed to convert DB message for delete event publishing", "id", dbMsg.ID, "error", convErr)
messages := make([]Message, len(dbMessages))
for i, dbMessage := range dbMessages {
messages[i], err = s.fromDBItem(dbMessage)
if err != nil {
return nil, err
}
}
return nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
return s.broker.Subscribe(ctx)
return messages, nil
}
func (s *service) fromDBItem(item db.Message) (Message, error) {
parts, err := unmarshallParts([]byte(item.Parts))
if err != nil {
return Message{}, fmt.Errorf("unmarshallParts for message ID %s: %w. Raw parts: %s", item.ID, err, item.Parts)
return Message{}, err
}
// Parse timestamps from ISO strings
createdAt, err := time.Parse(time.RFC3339Nano, item.CreatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
createdAt = time.Now() // Fallback
}
updatedAt, err := time.Parse(time.RFC3339Nano, item.UpdatedAt)
if err != nil {
slog.Error("Failed to parse created_at", "value", item.CreatedAt, "error", err)
updatedAt = time.Now() // Fallback
}
msg := Message{
return Message{
ID: item.ID,
SessionID: item.SessionID,
Role: MessageRole(item.Role),
Parts: parts,
Model: models.ModelID(item.Model.String),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
return msg, nil
}
func Create(ctx context.Context, sessionID string, params CreateMessageParams) (Message, error) {
return GetService().Create(ctx, sessionID, params)
}
func Update(ctx context.Context, message Message) (Message, error) {
return GetService().Update(ctx, message)
}
func Get(ctx context.Context, id string) (Message, error) {
return GetService().Get(ctx, id)
}
func List(ctx context.Context, sessionID string) ([]Message, error) {
return GetService().List(ctx, sessionID)
}
func ListAfter(ctx context.Context, sessionID string, timestamp time.Time) ([]Message, error) {
return GetService().ListAfter(ctx, sessionID, timestamp)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func DeleteSessionMessages(ctx context.Context, sessionID string) error {
return GetService().DeleteSessionMessages(ctx, sessionID)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
return GetService().Subscribe(ctx)
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}, nil
}
type partType string
@@ -365,139 +173,109 @@ const (
)
type partWrapper struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
Type partType `json:"type"`
Data ContentPart `json:"data"`
}
func marshallParts(parts []ContentPart) ([]byte, error) {
wrappedParts := make([]json.RawMessage, len(parts))
wrappedParts := make([]partWrapper, len(parts))
for i, part := range parts {
var typ partType
var dataBytes []byte
var err error
switch p := part.(type) {
switch part.(type) {
case ReasoningContent:
typ = reasoningType
dataBytes, err = json.Marshal(p)
case TextContent:
typ = textType
dataBytes, err = json.Marshal(p)
case *TextContent:
typ = textType
dataBytes, err = json.Marshal(p)
case ImageURLContent:
typ = imageURLType
dataBytes, err = json.Marshal(p)
case BinaryContent:
typ = binaryType
dataBytes, err = json.Marshal(p)
case ToolCall:
typ = toolCallType
dataBytes, err = json.Marshal(p)
case ToolResult:
typ = toolResultType
dataBytes, err = json.Marshal(p)
case Finish:
typ = finishType
var dbFinish DBFinish
dbFinish.Reason = p.Reason
dbFinish.Time = p.Time.UnixMilli()
dataBytes, err = json.Marshal(dbFinish)
default:
return nil, fmt.Errorf("unknown part type for marshalling: %T", part)
return nil, fmt.Errorf("unknown part type: %T", part)
}
if err != nil {
return nil, fmt.Errorf("failed to marshal part data for type %s: %w", typ, err)
wrappedParts[i] = partWrapper{
Type: typ,
Data: part,
}
wrapper := struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
}{Type: typ, Data: dataBytes}
wrappedBytes, err := json.Marshal(wrapper)
if err != nil {
return nil, fmt.Errorf("failed to marshal part wrapper for type %s: %w", typ, err)
}
wrappedParts[i] = wrappedBytes
}
return json.Marshal(wrappedParts)
}
func unmarshallParts(data []byte) ([]ContentPart, error) {
var rawMessages []json.RawMessage
if err := json.Unmarshal(data, &rawMessages); err != nil {
return nil, fmt.Errorf("failed to unmarshal parts data as array: %w. Data: %s", err, string(data))
temp := []json.RawMessage{}
if err := json.Unmarshal(data, &temp); err != nil {
return nil, err
}
parts := make([]ContentPart, 0, len(rawMessages))
for _, rawPart := range rawMessages {
var wrapper partWrapper
parts := make([]ContentPart, 0)
for _, rawPart := range temp {
var wrapper struct {
Type partType `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(rawPart, &wrapper); err != nil {
// Fallback for old format where parts might be just TextContent string
var text string
if errText := json.Unmarshal(rawPart, &text); errText == nil {
parts = append(parts, TextContent{Text: text})
continue
}
return nil, fmt.Errorf("failed to unmarshal part wrapper: %w. Raw part: %s", err, string(rawPart))
return nil, err
}
switch wrapper.Type {
case reasoningType:
var p ReasoningContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ReasoningContent: %w. Data: %s", err, string(wrapper.Data))
part := ReasoningContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case textType:
var p TextContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal TextContent: %w. Data: %s", err, string(wrapper.Data))
part := TextContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case imageURLType:
var p ImageURLContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ImageURLContent: %w. Data: %s", err, string(wrapper.Data))
part := ImageURLContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
case binaryType:
var p BinaryContent
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal BinaryContent: %w. Data: %s", err, string(wrapper.Data))
part := BinaryContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case toolCallType:
var p ToolCall
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ToolCall: %w. Data: %s", err, string(wrapper.Data))
part := ToolCall{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case toolResultType:
var p ToolResult
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal ToolResult: %w. Data: %s", err, string(wrapper.Data))
part := ToolResult{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, p)
parts = append(parts, part)
case finishType:
var p DBFinish
if err := json.Unmarshal(wrapper.Data, &p); err != nil {
return nil, fmt.Errorf("unmarshal Finish: %w. Data: %s", err, string(wrapper.Data))
part := Finish{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, Finish{Reason: FinishReason(p.Reason), Time: time.UnixMilli(p.Time)})
parts = append(parts, part)
default:
slog.Warn("Unknown part type during unmarshalling, attempting to parse as TextContent", "type", wrapper.Type, "data", string(wrapper.Data))
// Fallback: if type is unknown or empty, try to parse data as TextContent directly
var p TextContent
if err := json.Unmarshal(wrapper.Data, &p); err == nil {
parts = append(parts, p)
} else {
// If that also fails, log it but continue if possible, or return error
slog.Error("Failed to unmarshal unknown part type and fallback to TextContent failed", "type", wrapper.Type, "data", string(wrapper.Data), "error", err)
// Depending on strictness, you might return an error here:
// return nil, fmt.Errorf("unknown part type '%s' and failed fallback: %w", wrapper.Type, err)
}
return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
}
}
return parts, nil
}
+49 -176
View File
@@ -1,18 +1,14 @@
package permission
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"slices"
"sync"
"log/slog"
"github.com/google/uuid"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/pubsub"
)
var ErrorPermissionDenied = errors.New("permission denied")
@@ -36,141 +32,56 @@ type PermissionRequest struct {
Path string `json:"path"`
}
type PermissionResponse struct {
Request PermissionRequest
Granted bool
}
const (
EventPermissionRequested pubsub.EventType = "permission_requested"
EventPermissionGranted pubsub.EventType = "permission_granted"
EventPermissionDenied pubsub.EventType = "permission_denied"
EventPermissionPersisted pubsub.EventType = "permission_persisted"
)
type Service interface {
pubsub.Subscriber[PermissionRequest]
SubscribeToResponseEvents(ctx context.Context) <-chan pubsub.Event[PermissionResponse]
GrantPersistant(ctx context.Context, permission PermissionRequest)
Grant(ctx context.Context, permission PermissionRequest)
Deny(ctx context.Context, permission PermissionRequest)
Request(ctx context.Context, opts CreatePermissionRequest) bool
AutoApproveSession(ctx context.Context, sessionID string)
IsAutoApproved(ctx context.Context, sessionID string) bool
pubsub.Suscriber[PermissionRequest]
GrantPersistant(permission PermissionRequest)
Grant(permission PermissionRequest)
Deny(permission PermissionRequest)
Request(opts CreatePermissionRequest) bool
AutoApproveSession(sessionID string)
}
type permissionService struct {
broker *pubsub.Broker[PermissionRequest]
responseBroker *pubsub.Broker[PermissionResponse]
*pubsub.Broker[PermissionRequest]
sessionPermissions map[string][]PermissionRequest
sessionPermissions []PermissionRequest
pendingRequests sync.Map
autoApproveSessions map[string]bool
mu sync.RWMutex
autoApproveSessions []string
}
var globalPermissionService *permissionService
func InitService() error {
if globalPermissionService != nil {
return fmt.Errorf("permission service already initialized")
}
globalPermissionService = &permissionService{
broker: pubsub.NewBroker[PermissionRequest](),
responseBroker: pubsub.NewBroker[PermissionResponse](),
sessionPermissions: make(map[string][]PermissionRequest),
autoApproveSessions: make(map[string]bool),
}
return nil
}
func GetService() *permissionService {
if globalPermissionService == nil {
panic("permission service not initialized. Call permission.InitService() first.")
}
return globalPermissionService
}
func (s *permissionService) GrantPersistant(ctx context.Context, permission PermissionRequest) {
s.mu.Lock()
s.sessionPermissions[permission.SessionID] = append(s.sessionPermissions[permission.SessionID], permission)
s.mu.Unlock()
func (s *permissionService) GrantPersistant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- true:
case <-ctx.Done():
slog.Warn("Context cancelled while sending grant persistent response", "request_id", permission.ID)
}
respCh.(chan bool) <- true
}
s.responseBroker.Publish(EventPermissionPersisted, PermissionResponse{Request: permission, Granted: true})
s.sessionPermissions = append(s.sessionPermissions, permission)
}
func (s *permissionService) Grant(ctx context.Context, permission PermissionRequest) {
func (s *permissionService) Grant(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- true:
case <-ctx.Done():
slog.Warn("Context cancelled while sending grant response", "request_id", permission.ID)
}
respCh.(chan bool) <- true
}
s.responseBroker.Publish(EventPermissionGranted, PermissionResponse{Request: permission, Granted: true})
}
func (s *permissionService) Deny(ctx context.Context, permission PermissionRequest) {
func (s *permissionService) Deny(permission PermissionRequest) {
respCh, ok := s.pendingRequests.Load(permission.ID)
if ok {
select {
case respCh.(chan bool) <- false:
case <-ctx.Done():
slog.Warn("Context cancelled while sending deny response", "request_id", permission.ID)
}
respCh.(chan bool) <- false
}
s.responseBroker.Publish(EventPermissionDenied, PermissionResponse{Request: permission, Granted: false})
}
func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) bool {
s.mu.RLock()
if s.autoApproveSessions[opts.SessionID] {
s.mu.RUnlock()
func (s *permissionService) Request(opts CreatePermissionRequest) bool {
if slices.Contains(s.autoApproveSessions, opts.SessionID) {
return true
}
requestPath := opts.Path
if !filepath.IsAbs(requestPath) {
requestPath = filepath.Join(config.WorkingDirectory(), requestPath)
dir := filepath.Dir(opts.Path)
if dir == "." {
dir = config.WorkingDirectory()
}
requestPath = filepath.Clean(requestPath)
if permissions, ok := s.sessionPermissions[opts.SessionID]; ok {
for _, p := range permissions {
storedPath := p.Path
if !filepath.IsAbs(storedPath) {
storedPath = filepath.Join(config.WorkingDirectory(), storedPath)
}
storedPath = filepath.Clean(storedPath)
if p.ToolName == opts.ToolName && p.Action == opts.Action &&
(requestPath == storedPath || strings.HasPrefix(requestPath, storedPath+string(filepath.Separator))) {
s.mu.RUnlock()
return true
}
}
}
s.mu.RUnlock()
normalizedPath := opts.Path
if !filepath.IsAbs(normalizedPath) {
normalizedPath = filepath.Join(config.WorkingDirectory(), normalizedPath)
}
normalizedPath = filepath.Clean(normalizedPath)
permissionReq := PermissionRequest{
permission := PermissionRequest{
ID: uuid.New().String(),
Path: normalizedPath,
Path: dir,
SessionID: opts.SessionID,
ToolName: opts.ToolName,
Description: opts.Description,
@@ -178,69 +89,31 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
Params: opts.Params,
}
for _, p := range s.sessionPermissions {
if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
return true
}
}
respCh := make(chan bool, 1)
s.pendingRequests.Store(permissionReq.ID, respCh)
defer s.pendingRequests.Delete(permissionReq.ID)
s.broker.Publish(EventPermissionRequested, permissionReq)
s.pendingRequests.Store(permission.ID, respCh)
defer s.pendingRequests.Delete(permission.ID)
select {
case resp := <-respCh:
return resp
case <-ctx.Done():
slog.Warn("Permission request timed out or context cancelled", "request_id", permissionReq.ID, "tool", opts.ToolName)
return false
s.Publish(pubsub.CreatedEvent, permission)
// Wait for the response with a timeout
resp := <-respCh
return resp
}
func (s *permissionService) AutoApproveSession(sessionID string) {
s.autoApproveSessions = append(s.autoApproveSessions, sessionID)
}
func NewPermissionService() Service {
return &permissionService{
Broker: pubsub.NewBroker[PermissionRequest](),
sessionPermissions: make([]PermissionRequest, 0),
}
}
func (s *permissionService) AutoApproveSession(ctx context.Context, sessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoApproveSessions[sessionID] = true
}
func (s *permissionService) IsAutoApproved(ctx context.Context, sessionID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.autoApproveSessions[sessionID]
}
func (s *permissionService) Subscribe(ctx context.Context) <-chan pubsub.Event[PermissionRequest] {
return s.broker.Subscribe(ctx)
}
func (s *permissionService) SubscribeToResponseEvents(ctx context.Context) <-chan pubsub.Event[PermissionResponse] {
return s.responseBroker.Subscribe(ctx)
}
func GrantPersistant(ctx context.Context, permission PermissionRequest) {
GetService().GrantPersistant(ctx, permission)
}
func Grant(ctx context.Context, permission PermissionRequest) {
GetService().Grant(ctx, permission)
}
func Deny(ctx context.Context, permission PermissionRequest) {
GetService().Deny(ctx, permission)
}
func Request(ctx context.Context, opts CreatePermissionRequest) bool {
return GetService().Request(ctx, opts)
}
func AutoApproveSession(ctx context.Context, sessionID string) {
GetService().AutoApproveSession(ctx, sessionID)
}
func IsAutoApproved(ctx context.Context, sessionID string) bool {
return GetService().IsAutoApproved(ctx, sessionID)
}
func SubscribeToRequests(ctx context.Context) <-chan pubsub.Event[PermissionRequest] {
return GetService().Subscribe(ctx)
}
func SubscribeToResponses(ctx context.Context) <-chan pubsub.Event[PermissionResponse] {
return GetService().SubscribeToResponseEvents(ctx)
}
+74 -71
View File
@@ -2,112 +2,115 @@ package pubsub
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
)
const defaultChannelBufferSize = 100
const bufferSize = 64
type Broker[T any] struct {
subs map[chan Event[T]]context.CancelFunc
mu sync.RWMutex
isClosed bool
subs map[chan Event[T]]struct{}
mu sync.RWMutex
done chan struct{}
subCount int
maxEvents int
}
func NewBroker[T any]() *Broker[T] {
return &Broker[T]{
subs: make(map[chan Event[T]]context.CancelFunc),
return NewBrokerWithOptions[T](bufferSize, 1000)
}
func NewBrokerWithOptions[T any](channelBufferSize, maxEvents int) *Broker[T] {
b := &Broker[T]{
subs: make(map[chan Event[T]]struct{}),
done: make(chan struct{}),
subCount: 0,
maxEvents: maxEvents,
}
return b
}
func (b *Broker[T]) Shutdown() {
b.mu.Lock()
if b.isClosed {
b.mu.Unlock()
select {
case <-b.done: // Already closed
return
default:
close(b.done)
}
b.isClosed = true
for ch, cancel := range b.subs {
cancel()
close(ch)
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
delete(b.subs, ch)
close(ch)
}
b.mu.Unlock()
slog.Debug("PubSub broker shut down", "type", fmt.Sprintf("%T", *new(T)))
b.subCount = 0
}
func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
b.mu.Lock()
defer b.mu.Unlock()
if b.isClosed {
closedCh := make(chan Event[T])
close(closedCh)
return closedCh
select {
case <-b.done:
ch := make(chan Event[T])
close(ch)
return ch
default:
}
subCtx, subCancel := context.WithCancel(ctx)
subscriberChannel := make(chan Event[T], defaultChannelBufferSize)
b.subs[subscriberChannel] = subCancel
sub := make(chan Event[T], bufferSize)
b.subs[sub] = struct{}{}
b.subCount++
go func() {
<-subCtx.Done()
<-ctx.Done()
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.subs[subscriberChannel]; ok {
close(subscriberChannel)
delete(b.subs, subscriberChannel)
select {
case <-b.done:
return
default:
}
delete(b.subs, sub)
close(sub)
b.subCount--
}()
return subscriberChannel
}
func (b *Broker[T]) Publish(eventType EventType, payload T) {
b.mu.RLock()
defer b.mu.RUnlock()
if b.isClosed {
slog.Warn("Attempted to publish on a closed pubsub broker", "type", eventType, "payload_type", fmt.Sprintf("%T", payload))
return
}
event := Event[T]{Type: eventType, Payload: payload}
for ch := range b.subs {
// Non-blocking send with a fallback to a goroutine to prevent slow subscribers
// from blocking the publisher.
select {
case ch <- event:
// Successfully sent
default:
// Subscriber channel is full or receiver is slow.
// Send in a new goroutine to avoid blocking the publisher.
// This might lead to out-of-order delivery for this specific slow subscriber.
go func(sChan chan Event[T], ev Event[T]) {
// Re-check if broker is closed before attempting send in goroutine
b.mu.RLock()
isBrokerClosed := b.isClosed
b.mu.RUnlock()
if isBrokerClosed {
return
}
select {
case sChan <- ev:
case <-time.After(2 * time.Second): // Timeout for slow subscriber
slog.Warn("PubSub: Dropped event for slow subscriber after timeout", "type", ev.Type)
}
}(ch, event)
}
}
return sub
}
func (b *Broker[T]) GetSubscriberCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.subs)
return b.subCount
}
func (b *Broker[T]) Publish(t EventType, payload T) {
b.mu.RLock()
select {
case <-b.done:
b.mu.RUnlock()
return
default:
}
subscribers := make([]chan Event[T], 0, len(b.subs))
for sub := range b.subs {
subscribers = append(subscribers, sub)
}
b.mu.RUnlock()
event := Event[T]{Type: t, Payload: payload}
for _, sub := range subscribers {
select {
case sub <- event:
default:
}
}
}
-144
View File
@@ -1,144 +0,0 @@
package pubsub
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestBrokerSubscribe(t *testing.T) {
t.Parallel()
t.Run("with cancellable context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Cancel the context should remove the subscription
cancel()
time.Sleep(10 * time.Millisecond) // Give time for goroutine to process
assert.Equal(t, 0, broker.GetSubscriberCount())
})
t.Run("with background context", func(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Using context.Background() should not leak goroutines
ch := broker.Subscribe(context.Background())
assert.NotNil(t, ch)
assert.Equal(t, 1, broker.GetSubscriberCount())
// Shutdown should clean up all subscriptions
broker.Shutdown()
assert.Equal(t, 0, broker.GetSubscriberCount())
})
}
func TestBrokerPublish(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
ctx := t.Context()
ch := broker.Subscribe(ctx)
// Publish a message
broker.Publish(EventTypeCreated, "test message")
// Verify message is received
select {
case event := <-ch:
assert.Equal(t, EventTypeCreated, event.Type)
assert.Equal(t, "test message", event.Payload)
case <-time.After(100 * time.Millisecond):
t.Fatal("timeout waiting for message")
}
}
func TestBrokerShutdown(t *testing.T) {
t.Parallel()
broker := NewBroker[string]()
// Create multiple subscribers
ch1 := broker.Subscribe(context.Background())
ch2 := broker.Subscribe(context.Background())
assert.Equal(t, 2, broker.GetSubscriberCount())
// Shutdown should close all channels and clean up
broker.Shutdown()
// Verify channels are closed
_, ok1 := <-ch1
_, ok2 := <-ch2
assert.False(t, ok1, "channel 1 should be closed")
assert.False(t, ok2, "channel 2 should be closed")
// Verify subscriber count is reset
assert.Equal(t, 0, broker.GetSubscriberCount())
}
func TestBrokerConcurrency(t *testing.T) {
t.Parallel()
broker := NewBroker[int]()
// Create a large number of subscribers
const numSubscribers = 100
var wg sync.WaitGroup
wg.Add(numSubscribers)
// Create a channel to collect received events
receivedEvents := make(chan int, numSubscribers)
for i := range numSubscribers {
go func(id int) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := broker.Subscribe(ctx)
// Receive one message then cancel
select {
case event := <-ch:
receivedEvents <- event.Payload
case <-time.After(1 * time.Second):
t.Errorf("timeout waiting for message %d", id)
}
cancel()
}(i)
}
// Give subscribers time to set up
time.Sleep(10 * time.Millisecond)
// Publish messages to all subscribers
for i := range numSubscribers {
broker.Publish(EventTypeCreated, i)
}
// Wait for all subscribers to finish
wg.Wait()
close(receivedEvents)
// Give time for cleanup goroutines to run
time.Sleep(10 * time.Millisecond)
// Verify all subscribers are cleaned up
assert.Equal(t, 0, broker.GetSubscriberCount())
// Verify we received the expected number of events
count := 0
for range receivedEvents {
count++
}
assert.Equal(t, numSubscribers, count)
}
+18 -14
View File
@@ -2,23 +2,27 @@ package pubsub
import "context"
type EventType string
const (
EventTypeCreated EventType = "created"
EventTypeUpdated EventType = "updated"
EventTypeDeleted EventType = "deleted"
CreatedEvent EventType = "created"
UpdatedEvent EventType = "updated"
DeletedEvent EventType = "deleted"
)
type Event[T any] struct {
Type EventType
Payload T
type Suscriber[T any] interface {
Subscribe(context.Context) <-chan Event[T]
}
type Subscriber[T any] interface {
Subscribe(ctx context.Context) <-chan Event[T]
}
type (
// EventType identifies the type of event
EventType string
type Publisher[T any] interface {
Publish(eventType EventType, payload T)
}
// Event represents an event in the lifecycle of a resource
Event[T any] struct {
Type EventType
Payload T
}
Publisher[T any] interface {
Publish(EventType, T)
}
)
+71 -176
View File
@@ -3,13 +3,10 @@ package session
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/pubsub"
)
type Session struct {
@@ -20,197 +17,117 @@ type Session struct {
PromptTokens int64
CompletionTokens int64
Cost float64
Summary string
SummarizedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
CreatedAt int64
UpdatedAt int64
}
const (
EventSessionCreated pubsub.EventType = "session_created"
EventSessionUpdated pubsub.EventType = "session_updated"
EventSessionDeleted pubsub.EventType = "session_deleted"
)
type Service interface {
pubsub.Subscriber[Session]
pubsub.Suscriber[Session]
Create(ctx context.Context, title string) (Session, error)
CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error)
CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error)
Get(ctx context.Context, id string) (Session, error)
List(ctx context.Context) ([]Session, error)
Update(ctx context.Context, session Session) (Session, error)
Save(ctx context.Context, session Session) (Session, error)
Delete(ctx context.Context, id string) error
}
type service struct {
db *db.Queries
broker *pubsub.Broker[Session]
mu sync.RWMutex
}
var globalSessionService *service
func InitService(dbConn *sql.DB) error {
if globalSessionService != nil {
return fmt.Errorf("session service already initialized")
}
queries := db.New(dbConn)
broker := pubsub.NewBroker[Session]()
globalSessionService = &service{
db: queries,
broker: broker,
}
return nil
}
func GetService() Service {
if globalSessionService == nil {
panic("session service not initialized. Call session.InitService() first.")
}
return globalSessionService
*pubsub.Broker[Session]
q db.Querier
}
func (s *service) Create(ctx context.Context, title string) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if title == "" {
title = "New Session - " + time.Now().Format("2006-01-02 15:04:05")
}
dbSessParams := db.CreateSessionParams{
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: uuid.New().String(),
Title: title,
}
dbSession, err := s.db.CreateSession(ctx, dbSessParams)
})
if err != nil {
return Session{}, fmt.Errorf("db.CreateSession: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionCreated, session)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if title == "" {
title = "Task Session - " + time.Now().Format("2006-01-02 15:04:05")
}
if toolCallID == "" {
toolCallID = uuid.New().String()
}
dbSessParams := db.CreateSessionParams{
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: toolCallID,
ParentSessionID: sql.NullString{String: parentSessionID, Valid: parentSessionID != ""},
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: title,
}
dbSession, err := s.db.CreateSession(ctx, dbSessParams)
})
if err != nil {
return Session{}, fmt.Errorf("db.CreateTaskSession: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionCreated, session)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) Get(ctx context.Context, id string) (Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbSession, err := s.db.GetSessionByID(ctx, id)
func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) {
dbSession, err := s.q.CreateSession(ctx, db.CreateSessionParams{
ID: "title-" + parentSessionID,
ParentSessionID: sql.NullString{String: parentSessionID, Valid: true},
Title: "Generate a title",
})
if err != nil {
if err == sql.ErrNoRows {
return Session{}, fmt.Errorf("session ID '%s' not found", id)
}
return Session{}, fmt.Errorf("db.GetSessionByID: %w", err)
return Session{}, err
}
session := s.fromDBItem(dbSession)
s.Publish(pubsub.CreatedEvent, session)
return session, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
session, err := s.Get(ctx, id)
if err != nil {
return err
}
err = s.q.DeleteSession(ctx, session.ID)
if err != nil {
return err
}
s.Publish(pubsub.DeletedEvent, session)
return nil
}
func (s *service) Get(ctx context.Context, id string) (Session, error) {
dbSession, err := s.q.GetSessionByID(ctx, id)
if err != nil {
return Session{}, err
}
return s.fromDBItem(dbSession), nil
}
func (s *service) List(ctx context.Context) ([]Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
dbSessions, err := s.db.ListSessions(ctx)
if err != nil {
return nil, fmt.Errorf("db.ListSessions: %w", err)
}
sessions := make([]Session, len(dbSessions))
for i, dbSess := range dbSessions {
sessions[i] = s.fromDBItem(dbSess)
}
return sessions, nil
}
func (s *service) Update(ctx context.Context, session Session) (Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
if session.ID == "" {
return Session{}, fmt.Errorf("cannot update session with empty ID")
}
params := db.UpdateSessionParams{
func (s *service) Save(ctx context.Context, session Session) (Session, error) {
dbSession, err := s.q.UpdateSession(ctx, db.UpdateSessionParams{
ID: session.ID,
Title: session.Title,
PromptTokens: session.PromptTokens,
CompletionTokens: session.CompletionTokens,
Cost: session.Cost,
Summary: sql.NullString{String: session.Summary, Valid: session.Summary != ""},
SummarizedAt: sql.NullString{String: session.SummarizedAt.UTC().Format(time.RFC3339Nano), Valid: !session.SummarizedAt.IsZero()},
}
dbSession, err := s.db.UpdateSession(ctx, params)
})
if err != nil {
return Session{}, fmt.Errorf("db.UpdateSession: %w", err)
return Session{}, err
}
updatedSession := s.fromDBItem(dbSession)
s.broker.Publish(EventSessionUpdated, updatedSession)
return updatedSession, nil
session = s.fromDBItem(dbSession)
s.Publish(pubsub.UpdatedEvent, session)
return session, nil
}
func (s *service) Delete(ctx context.Context, id string) error {
s.mu.Lock()
dbSess, err := s.db.GetSessionByID(ctx, id)
func (s *service) List(ctx context.Context) ([]Session, error) {
dbSessions, err := s.q.ListSessions(ctx)
if err != nil {
s.mu.Unlock()
if err == sql.ErrNoRows {
return fmt.Errorf("session ID '%s' not found for deletion", id)
}
return fmt.Errorf("db.GetSessionByID before delete: %w", err)
return nil, err
}
sessionToPublish := s.fromDBItem(dbSess)
s.mu.Unlock()
s.mu.Lock()
defer s.mu.Unlock()
err = s.db.DeleteSession(ctx, id)
if err != nil {
return fmt.Errorf("db.DeleteSession: %w", err)
sessions := make([]Session, len(dbSessions))
for i, dbSession := range dbSessions {
sessions[i] = s.fromDBItem(dbSession)
}
s.broker.Publish(EventSessionDeleted, sessionToPublish)
return nil
return sessions, nil
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[Session] {
return s.broker.Subscribe(ctx)
}
func (s *service) fromDBItem(item db.Session) Session {
var summarizedAt time.Time
if item.SummarizedAt.Valid {
parsedTime, err := time.Parse(time.RFC3339Nano, item.SummarizedAt.String)
if err == nil {
summarizedAt = parsedTime
}
}
createdAt, _ := time.Parse(time.RFC3339Nano, item.CreatedAt)
updatedAt, _ := time.Parse(time.RFC3339Nano, item.UpdatedAt)
func (s service) fromDBItem(item db.Session) Session {
return Session{
ID: item.ID,
ParentSessionID: item.ParentSessionID.String,
@@ -219,37 +136,15 @@ func (s *service) fromDBItem(item db.Session) Session {
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
Cost: item.Cost,
Summary: item.Summary.String,
SummarizedAt: summarizedAt,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
func Create(ctx context.Context, title string) (Session, error) {
return GetService().Create(ctx, title)
}
func CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) {
return GetService().CreateTaskSession(ctx, toolCallID, parentSessionID, title)
}
func Get(ctx context.Context, id string) (Session, error) {
return GetService().Get(ctx, id)
}
func List(ctx context.Context) ([]Session, error) {
return GetService().List(ctx)
}
func Update(ctx context.Context, session Session) (Session, error) {
return GetService().Update(ctx, session)
}
func Delete(ctx context.Context, id string) error {
return GetService().Delete(ctx, id)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[Session] {
return GetService().Subscribe(ctx)
func NewService(q db.Querier) Service {
broker := pubsub.NewBroker[Session]()
return &service{
broker,
q,
}
}
-117
View File
@@ -1,117 +0,0 @@
package status
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/sst/opencode/internal/pubsub"
)
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelDebug Level = "debug"
)
type StatusMessage struct {
Level Level `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
const (
EventStatusPublished pubsub.EventType = "status_published"
)
type Service interface {
pubsub.Subscriber[StatusMessage]
Info(message string)
Warn(message string)
Error(message string)
Debug(message string)
}
type service struct {
broker *pubsub.Broker[StatusMessage]
mu sync.RWMutex
}
var globalStatusService *service
func InitService() error {
if globalStatusService != nil {
return fmt.Errorf("status service already initialized")
}
broker := pubsub.NewBroker[StatusMessage]()
globalStatusService = &service{
broker: broker,
}
return nil
}
func GetService() Service {
if globalStatusService == nil {
panic("status service not initialized. Call status.InitService() at application startup.")
}
return globalStatusService
}
func (s *service) Info(message string) {
s.publish(LevelInfo, message)
slog.Info(message)
}
func (s *service) Warn(message string) {
s.publish(LevelWarn, message)
slog.Warn(message)
}
func (s *service) Error(message string) {
s.publish(LevelError, message)
slog.Error(message)
}
func (s *service) Debug(message string) {
s.publish(LevelDebug, message)
slog.Debug(message)
}
func (s *service) publish(level Level, messageText string) {
statusMsg := StatusMessage{
Level: level,
Message: messageText,
Timestamp: time.Now(),
}
s.broker.Publish(EventStatusPublished, statusMsg)
}
func (s *service) Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return s.broker.Subscribe(ctx)
}
func Info(message string) {
GetService().Info(message)
}
func Warn(message string) {
GetService().Warn(message)
}
func Error(message string) {
GetService().Error(message)
}
func Debug(message string) {
GetService().Debug(message)
}
func Subscribe(ctx context.Context) <-chan pubsub.Event[StatusMessage] {
return GetService().Subscribe(ctx)
}
+15 -7
View File
@@ -6,11 +6,12 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/version"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/version"
)
type SendMsg struct {
@@ -18,6 +19,12 @@ type SendMsg struct {
Attachments []message.Attachment
}
type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
type EditorFocusMsg bool
func header(width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
@@ -30,7 +37,7 @@ func header(width int) string {
func lspsConfigured(width int) string {
cfg := config.Get()
title := "LSP Servers"
title := "LSP Configuration"
title = ansi.Truncate(title, width, "…")
t := theme.CurrentTheme()
@@ -113,7 +120,7 @@ func logo(width int) string {
}
func repo(width int) string {
repo := "github.com/sst/opencode"
repo := "https://github.com/opencode-ai/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
@@ -131,3 +138,4 @@ func cwd(width int) string {
Width(width).
Render(cwd)
}
+26 -28
View File
@@ -11,20 +11,22 @@ import (
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
type editorCmp struct {
width int
height int
app *app.App
session session.Session
textarea textarea.Model
attachments []message.Attachment
deleteMode bool
@@ -76,17 +78,15 @@ const (
maxAttachments = 5
)
func (m *editorCmp) openEditor(value string) tea.Cmd {
func (m *editorCmp) openEditor() tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
@@ -95,17 +95,14 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
return util.ReportWarn("Message is empty")
}
os.Remove(tmpfile.Name())
attachments := m.attachments
@@ -122,9 +119,8 @@ func (m *editorCmp) Init() tea.Cmd {
}
func (m *editorCmp) send() tea.Cmd {
if m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) {
status.Warn("Agent is working, please wait...")
return nil
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return util.ReportWarn("Agent is working, please wait...")
}
value := m.textarea.Value()
@@ -149,9 +145,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
return m, nil
case SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
}
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
@@ -182,13 +183,10 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
if m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) {
status.Warn("Agent is working, please wait...")
return m, nil
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return m, util.ReportWarn("Agent is working, please wait...")
}
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
return m, m.openEditor()
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
+54 -61
View File
@@ -4,44 +4,40 @@ import (
"context"
"fmt"
"math"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/session"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/components/dialog"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/dialog"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
type cacheItem struct {
width int
content []uiMessage
}
type messagesCmp struct {
app *app.App
width, height int
viewport viewport.Model
messages []message.Message
uiMessages []uiMessage
currentMsgID string
cachedContent map[string]cacheItem
spinner spinner.Model
rendering bool
attachments viewport.Model
showToolMessages bool
app *app.App
width, height int
viewport viewport.Model
session session.Session
messages []message.Message
uiMessages []uiMessage
currentMsgID string
cachedContent map[string]cacheItem
spinner spinner.Model
rendering bool
attachments viewport.Model
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
@@ -79,20 +75,19 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeChangedMsg:
m.rerender()
return m, nil
case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages
// Clear the cache to force re-rendering of all messages
m.cachedContent = make(map[string]cacheItem)
m.renderView()
case SessionSelectedMsg:
if msg.ID != m.session.ID {
cmd := m.SetSession(msg)
return m, cmd
}
return m, nil
case state.SessionSelectedMsg:
cmd := m.Reload(msg)
return m, cmd
case state.SessionClearedMsg:
case SessionClearedMsg:
m.session = session.Session{}
m.messages = make([]message.Message, 0)
m.currentMsgID = ""
m.rendering = false
return m, nil
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
@@ -100,13 +95,15 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport = u
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
m.viewport.GotoBottom()
case pubsub.Event[message.Message]:
needsRerender := false
if msg.Type == message.EventMessageCreated {
if msg.Payload.SessionID == m.app.CurrentSession.ID {
if msg.Type == pubsub.CreatedEvent {
if msg.Payload.SessionID == m.session.ID {
messageExists := false
for _, v := range m.messages {
if v.ID == msg.Payload.ID {
@@ -136,7 +133,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
} else if msg.Type == message.EventMessageUpdated && msg.Payload.SessionID == m.app.CurrentSession.ID {
} 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
@@ -149,8 +146,8 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if needsRerender {
m.renderView()
if len(m.messages) > 0 {
if (msg.Type == message.EventMessageCreated) ||
(msg.Type == message.EventMessageUpdated && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
if (msg.Type == pubsub.CreatedEvent) ||
(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
m.viewport.GotoBottom()
}
}
@@ -164,7 +161,7 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *messagesCmp) IsAgentWorking() bool {
return m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID)
return m.app.CoderAgent.IsSessionBusy(m.session.ID)
}
func formatTimeDifference(unixTime1, unixTime2 int64) string {
@@ -219,7 +216,6 @@ func (m *messagesCmp) renderView() {
m.currentMsgID,
m.width,
pos,
m.showToolMessages,
)
for _, msg := range assistantMessages {
m.uiMessages = append(m.uiMessages, msg)
@@ -370,24 +366,22 @@ func (m *messagesCmp) help() string {
text := ""
if m.app.PrimaryAgent.IsBusy() {
if m.app.CoderAgent.IsBusy() {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
)
} else {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
)
}
return baseStyle.
@@ -433,11 +427,14 @@ func (m *messagesCmp) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesCmp) Reload(session *session.Session) tea.Cmd {
func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
if m.session.ID == session.ID {
return nil
}
m.session = session
messages, err := m.app.Messages.List(context.Background(), session.ID)
if err != nil {
status.Error(err.Error())
return nil
return util.ReportError(err)
}
m.messages = messages
if len(m.messages) > 0 {
@@ -461,11 +458,8 @@ func (m *messagesCmp) BindingKeys() []key.Binding {
}
func NewMessagesCmp(app *app.App) tea.Model {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
s := spinner.New()
s.Spinner = spinner.Pulse
vp := viewport.New(0, 0)
attachmets := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
@@ -473,11 +467,10 @@ func NewMessagesCmp(app *app.App) tea.Model {
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{
app: app,
cachedContent: make(map[string]cacheItem),
viewport: vp,
spinner: s,
attachments: attachmets,
showToolMessages: true,
app: app,
cachedContent: make(map[string]cacheItem),
viewport: vp,
spinner: s,
attachments: attachmets,
}
}
+67 -76
View File
@@ -10,14 +10,14 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/llm/tools"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/llm/agent"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type uiMessageType int
@@ -82,8 +82,7 @@ func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...s
func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
var styledAttachments []string
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
attachmentStyles := baseStyle.
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
@@ -97,23 +96,12 @@ func renderUserMessage(msg message.Message, isFocused bool, width int, position
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
// Add timestamp info
info := []string{}
timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM")
username, _ := config.GetUsername()
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", username, timestamp)),
)
content := ""
if len(styledAttachments) > 0 {
attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
attachmentContent := styles.BaseStyle().Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
content = renderMessage(msg.Content().String(), true, isFocused, width, attachmentContent)
} else {
content = renderMessage(msg.Content().String(), true, isFocused, width, info...)
content = renderMessage(msg.Content().String(), true, isFocused, width)
}
userMsg := uiMessage{
ID: msg.ID,
@@ -134,10 +122,9 @@ func renderAssistantMessage(
focusedUIMessageId string,
width int,
position int,
showToolMessages bool,
) []uiMessage {
messages := []uiMessage{}
content := strings.TrimSpace(msg.Content().String())
content := msg.Content().String()
thinking := msg.IsThinking()
thinkingContent := msg.ReasoningContent().Thinking
finished := msg.IsFinished()
@@ -147,43 +134,36 @@ func renderAssistantMessage(
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Always add timestamp info
timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM")
modelName := "Assistant"
if msg.Model != "" {
modelName = models.SupportedModels[msg.Model].Name
}
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", modelName, timestamp)),
)
// Add finish info if available
if finished {
// Add finish info if available
switch finishData.Reason {
case message.FinishReasonEndTurn:
took := formatTimestampDiff(msg.CreatedAt, finishData.Time)
info = append(info, baseStyle.
Width(width-1).
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took)),
)
case message.FinishReasonCanceled:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Warning()).
Render("(canceled)"),
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled")),
)
case message.FinishReasonError:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Error()).
Render("(error)"),
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error")),
)
case message.FinishReasonPermissionDenied:
info = append(info, baseStyle.
Width(width-1).
Foreground(t.Info()).
Render("(permission denied)"),
Foreground(t.TextMuted()).
Render(fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied")),
)
}
}
if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
if content == "" {
content = "*Finished without output*"
@@ -200,35 +180,23 @@ func renderAssistantMessage(
position += messages[0].height
position++ // for the space
} else if thinking && thinkingContent != "" {
// Render the thinking content with timestamp
content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width, info...)
messages = append(messages, uiMessage{
ID: msg.ID,
messageType: assistantMessageType,
position: position,
height: lipgloss.Height(content),
content: content,
})
position += lipgloss.Height(content)
position++ // for the space
// Render the thinking content
content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
}
// Only render tool messages if they should be shown
if showToolMessages {
for i, toolCall := range msg.ToolCalls() {
toolCallContent := renderToolMessage(
toolCall,
allMessages,
messagesService,
focusedUIMessageId,
false,
width,
i+1,
)
messages = append(messages, toolCallContent)
position += toolCallContent.height
position++ // for the space
}
for i, toolCall := range msg.ToolCalls() {
toolCallContent := renderToolMessage(
toolCall,
allMessages,
messagesService,
focusedUIMessageId,
false,
width,
i+1,
)
messages = append(messages, toolCallContent)
position += toolCallContent.height
position++ // for the space
}
return messages
}
@@ -260,6 +228,8 @@ func toolName(name string) string {
return "Grep"
case tools.LSToolName:
return "List"
case tools.SourcegraphToolName:
return "Sourcegraph"
case tools.ViewToolName:
return "View"
case tools.WriteToolName:
@@ -286,6 +256,8 @@ func getToolAction(name string) string {
return "Searching content..."
case tools.LSToolName:
return "Listing directory..."
case tools.SourcegraphToolName:
return "Searching code..."
case tools.ViewToolName:
return "Reading file..."
case tools.WriteToolName:
@@ -424,6 +396,10 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
path = "."
}
return renderParams(paramWidth, path)
case tools.SourcegraphToolName:
var params tools.SourcegraphParams
json.Unmarshal([]byte(toolCall.Input), &params)
return renderParams(paramWidth, params.Query)
case tools.ViewToolName:
var params tools.ViewParams
json.Unmarshal([]byte(toolCall.Input), &params)
@@ -487,7 +463,8 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
case tools.EditToolName:
metadata := tools.EditResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width))
return formattedDiff
case tools.FetchToolName:
var params tools.FetchParams
@@ -510,6 +487,8 @@ func renderToolResponse(toolCall message.ToolCall, response message.ToolResult,
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.LSToolName:
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.SourcegraphToolName:
return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
case tools.ViewToolName:
metadata := tools.ViewResponseMetadata{}
json.Unmarshal([]byte(response.Metadata), &metadata)
@@ -595,7 +574,7 @@ func renderToolMessage(
return toolMsg
}
params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
params := renderToolParams(width-2-lipgloss.Width(toolNameText), toolCall)
responseContent := ""
if response != nil {
responseContent = renderToolResponse(toolCall, *response, width-2)
@@ -662,3 +641,15 @@ func renderToolMessage(
}
return toolMsg
}
// Helper function to format the time difference between two Unix timestamps
func formatTimestampDiff(start, end int64) string {
diffSeconds := float64(end-start) / 1000.0 // Convert to seconds
if diffSeconds < 1 {
return fmt.Sprintf("%dms", int(diffSeconds*1000))
}
if diffSeconds < 60 {
return fmt.Sprintf("%.1fs", diffSeconds)
}
return fmt.Sprintf("%.1fm", diffSeconds/60)
}
+39 -22
View File
@@ -8,19 +8,19 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/diff"
"github.com/sst/opencode/internal/history"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui/state"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/diff"
"github.com/opencode-ai/opencode/internal/history"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type sidebarCmp struct {
app *app.App
width, height int
session session.Session
history history.Service
modFiles map[string]struct {
additions int
removals int
@@ -28,10 +28,10 @@ type sidebarCmp struct {
}
func (m *sidebarCmp) Init() tea.Cmd {
if m.app.History != nil {
if m.history != nil {
ctx := context.Background()
// Subscribe to file events
filesCh := m.app.History.Subscribe(ctx)
filesCh := m.history.Subscribe(ctx)
// Initialize the modified files map
m.modFiles = make(map[string]struct {
@@ -52,14 +52,30 @@ func (m *sidebarCmp) Init() tea.Cmd {
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case state.SessionSelectedMsg:
ctx := context.Background()
m.loadModifiedFiles(ctx)
case SessionSelectedMsg:
if msg.ID != m.session.ID {
m.session = msg
ctx := context.Background()
m.loadModifiedFiles(ctx)
}
case pubsub.Event[session.Session]:
if msg.Type == pubsub.UpdatedEvent {
if m.session.ID == msg.Payload.ID {
m.session = msg.Payload
}
}
case pubsub.Event[history.File]:
if msg.Payload.SessionID == m.app.CurrentSession.ID {
if msg.Payload.SessionID == m.session.ID {
// Process the individual file change instead of reloading all files
ctx := context.Background()
m.processFileChanges(ctx, msg.Payload)
// Return a command to continue receiving events
return m, func() tea.Msg {
ctx := context.Background()
filesCh := m.history.Subscribe(ctx)
return <-filesCh
}
}
}
return m, nil
@@ -99,7 +115,7 @@ func (m *sidebarCmp) sessionSection() string {
sessionValue := baseStyle.
Foreground(t.Text()).
Width(m.width - lipgloss.Width(sessionKey)).
Render(fmt.Sprintf(": %s", m.app.CurrentSession.Title))
Render(fmt.Sprintf(": %s", m.session.Title))
return lipgloss.JoinHorizontal(
lipgloss.Left,
@@ -219,25 +235,26 @@ func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
func NewSidebarCmp(app *app.App) tea.Model {
func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
return &sidebarCmp{
app: app,
session: session,
history: history,
}
}
func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
if m.app.CurrentSession.ID == "" {
if m.history == nil || m.session.ID == "" {
return
}
// Get all latest files for this session
latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID)
latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
if err != nil {
return
}
// Get all files for this session (to find initial versions)
allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
allFiles, err := m.history.ListBySession(ctx, m.session.ID)
if err != nil {
return
}
@@ -338,7 +355,7 @@ func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File)
// Helper function to find the initial version of a file
func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
// Get all versions of this file for the session
fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID)
fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
if err != nil {
return history.File{}, err
}
+103 -117
View File
@@ -7,15 +7,16 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/llm/models"
"github.com/sst/opencode/internal/lsp"
"github.com/sst/opencode/internal/lsp/protocol"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/models"
"github.com/opencode-ai/opencode/internal/lsp"
"github.com/opencode-ai/opencode/internal/lsp/protocol"
"github.com/opencode-ai/opencode/internal/pubsub"
"github.com/opencode-ai/opencode/internal/session"
"github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
type StatusCmp interface {
@@ -24,33 +25,22 @@ type StatusCmp interface {
}
type statusCmp struct {
app *app.App
statusMessages []statusMessage
width int
messageTTL time.Duration
}
type statusMessage struct {
Level status.Level
Message string
Timestamp time.Time
ExpiresAt time.Time
info util.InfoMsg
width int
messageTTL time.Duration
lspClients map[string]*lsp.Client
session session.Session
}
// clearMessageCmd is a command that clears status messages after a timeout
func (m statusCmp) clearMessageCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return statusCleanupMsg{time: t}
func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
return tea.Tick(ttl, func(time.Time) tea.Msg {
return util.ClearStatusMsg{}
})
}
// statusCleanupMsg is a message that triggers cleanup of expired status messages
type statusCleanupMsg struct {
time time.Time
}
func (m statusCmp) Init() tea.Cmd {
return m.clearMessageCmd()
return nil
}
func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -58,26 +48,25 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case pubsub.Event[status.StatusMessage]:
if msg.Type == status.EventStatusPublished {
statusMsg := statusMessage{
Level: msg.Payload.Level,
Message: msg.Payload.Message,
Timestamp: msg.Payload.Timestamp,
ExpiresAt: msg.Payload.Timestamp.Add(m.messageTTL),
}
m.statusMessages = append(m.statusMessages, statusMsg)
}
case statusCleanupMsg:
// Remove expired messages
var activeMessages []statusMessage
for _, sm := range m.statusMessages {
if sm.ExpiresAt.After(msg.time) {
activeMessages = append(activeMessages, sm)
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
}
}
m.statusMessages = activeMessages
return m, m.clearMessageCmd()
case util.InfoMsg:
m.info = msg
ttl := msg.TTL
if ttl == 0 {
ttl = m.messageTTL
}
return m, m.clearMessageCmd(ttl)
case util.ClearStatusMsg:
m.info = util.InfoMsg{}
}
return m, nil
}
@@ -98,7 +87,7 @@ func getHelpWidget(helpText string) string {
Render(helpText)
}
func formatTokensAndCost(tokens int64, contextWindow int64, cost float64) string {
func formatTokensAndCost(tokens int64, cost float64) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@@ -121,21 +110,17 @@ func formatTokensAndCost(tokens int64, contextWindow int64, cost float64) string
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
}
func (m statusCmp) View() string {
t := theme.CurrentTheme()
modelID := config.Get().Agents[config.AgentPrimary].Model
model := models.SupportedModels[modelID]
// Initialize the help widget
status := getHelpWidget("")
if m.app.CurrentSession.ID != "" {
tokens := formatTokensAndCost(m.app.CurrentSession.PromptTokens+m.app.CurrentSession.CompletionTokens, model.ContextWindow, m.app.CurrentSession.Cost)
if m.session.ID != "" {
tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
tokensStyle := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
@@ -143,54 +128,41 @@ func (m statusCmp) View() string {
status += tokensStyle
}
diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
diagnostics := styles.Padded().
Background(t.BackgroundDarker()).
Render(m.projectDiagnostics())
modelName := m.model()
statusWidth := max(
0,
m.width-
lipgloss.Width(status)-
lipgloss.Width(modelName)-
lipgloss.Width(diagnostics),
)
// Display the first status message if available
if len(m.statusMessages) > 0 {
sm := m.statusMessages[0]
if m.info.Msg != "" {
infoStyle := styles.Padded().
Foreground(t.Background()).
Width(statusWidth)
Width(m.availableFooterMsgWidth(diagnostics))
switch sm.Level {
case "info":
switch m.info.Type {
case util.InfoTypeInfo:
infoStyle = infoStyle.Background(t.Info())
case "warn":
case util.InfoTypeWarn:
infoStyle = infoStyle.Background(t.Warning())
case "error":
case util.InfoTypeError:
infoStyle = infoStyle.Background(t.Error())
case "debug":
infoStyle = infoStyle.Background(t.TextMuted())
}
// Truncate message if it's longer than available width
msg := sm.Message
availWidth := statusWidth - 10
msg := m.info.Msg
availWidth := m.availableFooterMsgWidth(diagnostics) - 10
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
status += infoStyle.Render(msg)
} else {
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Width(m.availableFooterMsgWidth(diagnostics)).
Render("")
}
status += diagnostics
status += modelName
status += m.model()
return status
}
@@ -199,7 +171,7 @@ func (m *statusCmp) projectDiagnostics() string {
// Check if any LSP server is still initializing
initializing := false
for _, client := range m.app.LSPClients {
for _, client := range m.lspClients {
if client.GetServerState() == lsp.StateStarting {
initializing = true
break
@@ -209,6 +181,7 @@ func (m *statusCmp) projectDiagnostics() string {
// If any server is initializing, show that status
if initializing {
return lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Warning()).
Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
}
@@ -217,7 +190,7 @@ func (m *statusCmp) projectDiagnostics() string {
warnDiagnostics := []protocol.Diagnostic{}
hintDiagnostics := []protocol.Diagnostic{}
infoDiagnostics := []protocol.Diagnostic{}
for _, client := range m.app.LSPClients {
for _, client := range m.lspClients {
for _, d := range client.GetDiagnostics() {
for _, diag := range d {
switch diag.Severity {
@@ -234,36 +207,52 @@ func (m *statusCmp) projectDiagnostics() string {
}
}
if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
return "No diagnostics"
}
diagnostics := []string{}
errStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Error()).
Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
diagnostics = append(diagnostics, errStr)
if len(errorDiagnostics) > 0 {
errStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Error()).
Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
diagnostics = append(diagnostics, errStr)
}
if len(warnDiagnostics) > 0 {
warnStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Warning()).
Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
diagnostics = append(diagnostics, warnStr)
}
if len(hintDiagnostics) > 0 {
hintStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Text()).
Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
diagnostics = append(diagnostics, hintStr)
}
if len(infoDiagnostics) > 0 {
infoStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Info()).
Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
diagnostics = append(diagnostics, infoStr)
}
warnStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Warning()).
Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
diagnostics = append(diagnostics, warnStr)
return strings.Join(diagnostics, " ")
}
infoStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Info()).
Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
diagnostics = append(diagnostics, infoStr)
hintStr := lipgloss.NewStyle().
Background(t.BackgroundDarker()).
Foreground(t.Text()).
Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
diagnostics = append(diagnostics, hintStr)
return styles.ForceReplaceBackgroundWithLipgloss(
styles.Padded().Render(strings.Join(diagnostics, " ")),
t.BackgroundDarker(),
)
func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
tokens := ""
tokensWidth := 0
if m.session.ID != "" {
tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
tokensWidth = lipgloss.Width(tokens) + 2
}
return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
}
func (m statusCmp) model() string {
@@ -271,7 +260,7 @@ func (m statusCmp) model() string {
cfg := config.Get()
coder, ok := cfg.Agents[config.AgentPrimary]
coder, ok := cfg.Agents[config.AgentCoder]
if !ok {
return "Unknown"
}
@@ -288,15 +277,12 @@ func (m statusCmp) SetHelpWidgetMsg(s string) {
helpWidget = getHelpWidget(s)
}
func NewStatusCmp(app *app.App) StatusCmp {
func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
// Initialize the help widget with default text
helpWidget = getHelpWidget("")
statusComponent := &statusCmp{
app: app,
statusMessages: []statusMessage{},
messageTTL: 4 * time.Second,
return &statusCmp{
messageTTL: 10 * time.Second,
lspClients: lspClients,
}
return statusComponent
}
+4 -3
View File
@@ -6,9 +6,9 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// ArgumentsDialogCmp is a component that asks the user for command arguments.
@@ -170,3 +170,4 @@ type ShowArgumentsDialogMsg struct {
CommandID string
Content string
}
+5 -5
View File
@@ -4,10 +4,10 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/layout"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/opencode-ai/opencode/internal/tui/layout"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// Command represents a command that can be executed
@@ -115,7 +115,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *commandDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
if len(c.commands) == 0 {
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
@@ -7,8 +7,8 @@ import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/tui/util"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// Command prefix constants
+22 -23
View File
@@ -14,15 +14,14 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/tui/image"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"log/slog"
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
"github.com/opencode-ai/opencode/internal/tui/image"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
const (
@@ -160,7 +159,7 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
path = f.cwd.Value()
fileInfo, err := os.Stat(path)
if err != nil {
status.Error("Invalid path")
logging.ErrorPersist("Invalid path")
return f, cmd
}
isPathDir = fileInfo.IsDir()
@@ -225,29 +224,29 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
modeInfo := GetSelectedModel(config.Get())
if !modeInfo.SupportsAttachments {
status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
return f, nil
}
selectedFilePath := f.selectedFile
if !isExtSupported(selectedFilePath) {
status.Error("Unsupported file")
logging.ErrorPersist("Unsupported file")
return f, nil
}
isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
if err != nil {
status.Error("unable to read the image")
logging.ErrorPersist("unable to read the image")
return f, nil
}
if isFileLarge {
status.Error("file too large, max 5MB")
logging.ErrorPersist("file too large, max 5MB")
return f, nil
}
content, err := os.ReadFile(selectedFilePath)
if err != nil {
status.Error("Unable read selected file")
logging.ErrorPersist("Unable read selected file")
return f, nil
}
@@ -373,7 +372,7 @@ func (f *filepickerCmp) IsCWDFocused() bool {
func NewFilepickerCmp(app *app.App) FilepickerCmp {
homepath, err := os.UserHomeDir()
if err != nil {
slog.Error("error loading user files")
logging.Error("error loading user files")
return nil
}
baseDir := DirNode{parent: nil, directory: homepath}
@@ -389,7 +388,7 @@ func NewFilepickerCmp(app *app.App) FilepickerCmp {
func (f *filepickerCmp) getCurrentFileBelowCursor() {
if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
f.viewport.SetContent("Preview unavailable")
return
}
@@ -402,7 +401,7 @@ func (f *filepickerCmp) getCurrentFileBelowCursor() {
go func() {
imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
if err != nil {
slog.Error(err.Error())
logging.Error(err.Error())
f.viewport.SetContent("Preview unavailable")
return
}
@@ -415,7 +414,7 @@ func (f *filepickerCmp) getCurrentFileBelowCursor() {
}
func readDir(path string, showHidden bool) []os.DirEntry {
slog.Info(fmt.Sprintf("Reading directory: %s", path))
logging.Info(fmt.Sprintf("Reading directory: %s", path))
entriesChan := make(chan []os.DirEntry, 1)
errChan := make(chan error, 1)
@@ -423,7 +422,7 @@ func readDir(path string, showHidden bool) []os.DirEntry {
go func() {
dirEntries, err := os.ReadDir(path)
if err != nil {
status.Error(err.Error())
logging.ErrorPersist(err.Error())
errChan <- err
return
}
@@ -455,12 +454,12 @@ func readDir(path string, showHidden bool) []os.DirEntry {
return sanitizedDirEntries
case <-errChan:
status.Error(fmt.Sprintf("Error reading directory %s", path))
case err := <-errChan:
logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
return []os.DirEntry{}
case <-time.After(5 * time.Second):
status.Error(fmt.Sprintf("Timeout reading directory %s", path))
logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
return []os.DirEntry{}
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
)
type helpCmp struct {
+5 -5
View File
@@ -5,9 +5,9 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
"github.com/sst/opencode/internal/tui/util"
"github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
// InitDialogCmp is a component that asks the user if they want to initialize the project.
@@ -95,7 +95,7 @@ func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
@@ -110,7 +110,7 @@ func (m InitDialogCmp) View() string {
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
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.")
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.")
question := baseStyle.
Foreground(t.Text()).

Some files were not shown because too many files have changed in this diff Show More