Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9345830c8a | ||
|
|
5307100f89 | ||
|
|
a58e607c5f | ||
|
|
cd04c44517 | ||
|
|
88711db796 | ||
|
|
9fec8df7d0 | ||
|
|
603a3e3c71 | ||
|
|
e14de7a211 | ||
|
|
004cfe7e8e | ||
|
|
58705a1352 | ||
|
|
82de14371d |
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,3 +1,8 @@
|
||||
{
|
||||
"$schema": "./opencode-schema.json"
|
||||
"$schema": "./opencode-schema.json",
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"command": "gopls"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-24
@@ -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
|
||||
@@ -1,7 +1,5 @@
|
||||
# ⌬ OpenCode
|
||||
|
||||

|
||||
|
||||
> **⚠️ 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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -58,3 +58,4 @@ func MarkProjectInitialized() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+13
-40
@@ -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
@@ -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,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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
|
||||
@@ -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 ?;
|
||||
@@ -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 = ?;
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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)
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/opencode-ai/opencode/internal/config"
|
||||
)
|
||||
|
||||
type GrepParams struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/opencode-ai/opencode/internal/config"
|
||||
)
|
||||
|
||||
type LSParams struct {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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), ¶ms); 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
|
||||
}
|
||||
@@ -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), ¶ms); 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
|
||||
}
|
||||
@@ -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), ¶ms); 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
|
||||
}
|
||||
@@ -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), ¶ms); 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")
|
||||
}
|
||||
|
||||
@@ -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), ¶ms); 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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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), ¶ms); 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
|
||||
}
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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, ®isterParams); 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), ¶ms)
|
||||
return renderParams(paramWidth, params.Query)
|
||||
case tools.ViewToolName:
|
||||
var params tools.ViewParams
|
||||
json.Unmarshal([]byte(toolCall.Input), ¶ms)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user