add initial lsp support

This commit is contained in:
Kujtim Hoxha 2025-04-03 15:20:15 +02:00
parent afd9ad0560
commit cfdd687216
47 changed files with 13996 additions and 456 deletions

View file

@ -91,6 +91,12 @@ go build -o termai
./termai
```
## Acknowledgments
TermAI builds upon the work of several open source projects and developers:
- [@isaacphi](https://github.com/isaacphi) - LSP client implementation
## License
[License information coming soon]

4
cmd/lsp/main.go Normal file
View file

@ -0,0 +1,4 @@
package main
func main() {
}

View file

@ -36,6 +36,7 @@ var rootCmd = &cobra.Command{
ctx := context.Background()
app := app.New(ctx, conn)
defer app.Close()
app.Logger.Info("Starting termai...")
zone.NewGlobal()
tui := tea.NewProgram(

6
go.mod
View file

@ -1,6 +1,8 @@
module github.com/kujtimiihoxha/termai
go 1.23.5
go 1.24.0
toolchain go1.24.2
require (
github.com/anthropics/anthropic-sdk-go v0.2.0-beta.2
@ -11,6 +13,7 @@ require (
github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.8.0
github.com/go-logfmt/logfmt v0.6.0
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/generative-ai-go v0.19.0
@ -53,7 +56,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect

View file

@ -7,6 +7,8 @@ import (
"github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/db"
"github.com/kujtimiihoxha/termai/internal/logging"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/lsp/watcher"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/kujtimiihoxha/termai/internal/session"
@ -19,23 +21,59 @@ type App struct {
Messages message.Service
Permissions permission.Service
LSPClients map[string]*lsp.Client
Logger logging.Interface
ceanups []func()
}
func New(ctx context.Context, conn *sql.DB) *App {
cfg := config.Get()
q := db.New(conn)
log := logging.NewLogger(logging.Options{
Level: config.Get().Log.Level,
Level: cfg.Log.Level,
})
sessions := session.NewService(ctx, q)
messages := message.NewService(ctx, q)
return &App{
app := &App{
Context: ctx,
Sessions: sessions,
Messages: messages,
Permissions: permission.Default,
Logger: log,
LSPClients: make(map[string]*lsp.Client),
}
for name, client := range cfg.LSP {
lspClient, err := lsp.NewClient(client.Command, client.Args...)
app.ceanups = append(app.ceanups, func() {
lspClient.Close()
})
workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
if err != nil {
log.Error("Failed to create LSP client for", name, err)
continue
}
_, err = lspClient.InitializeLSPClient(ctx, config.WorkingDirectory())
if err != nil {
log.Error("Initialize failed", "error", err)
continue
}
go workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
app.LSPClients[name] = lspClient
}
return app
}
func (a *App) Close() {
for _, cleanup := range a.ceanups {
cleanup()
}
for _, client := range a.LSPClients {
client.Close()
}
a.Logger.Info("App closed")
}

View file

@ -49,12 +49,21 @@ type Log struct {
Level string `json:"level"`
}
type LSPConfig struct {
Disabled bool `json:"enabled"`
Command string `json:"command"`
Args []string `json:"args"`
Options any `json:"options"`
}
type Config struct {
Data *Data `json:"data,omitempty"`
Log *Log `json:"log,omitempty"`
MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
LSP map[string]LSPConfig `json:"lsp,omitempty"`
Model *Model `json:"model,omitempty"`
}

View file

@ -7,7 +7,6 @@ package db
import (
"context"
"database/sql"
)
const createMessage = `-- name: CreateMessage :one
@ -15,26 +14,20 @@ INSERT INTO messages (
id,
session_id,
role,
finished,
content,
tool_calls,
tool_results,
parts,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
RETURNING id, session_id, role, parts, created_at, updated_at
`
type CreateMessageParams struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Finished bool `json:"finished"`
Content string `json:"content"`
ToolCalls sql.NullString `json:"tool_calls"`
ToolResults sql.NullString `json:"tool_results"`
ID string `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Parts string `json:"parts"`
}
func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (Message, error) {
@ -42,21 +35,14 @@ func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (M
arg.ID,
arg.SessionID,
arg.Role,
arg.Finished,
arg.Content,
arg.ToolCalls,
arg.ToolResults,
arg.Parts,
)
var i Message
err := row.Scan(
&i.ID,
&i.SessionID,
&i.Role,
&i.Content,
&i.Thinking,
&i.Finished,
&i.ToolCalls,
&i.ToolResults,
&i.Parts,
&i.CreatedAt,
&i.UpdatedAt,
)
@ -84,7 +70,7 @@ func (q *Queries) DeleteSessionMessages(ctx context.Context, sessionID string) e
}
const getMessage = `-- name: GetMessage :one
SELECT id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
SELECT id, session_id, role, parts, created_at, updated_at
FROM messages
WHERE id = ? LIMIT 1
`
@ -96,11 +82,7 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
&i.ID,
&i.SessionID,
&i.Role,
&i.Content,
&i.Thinking,
&i.Finished,
&i.ToolCalls,
&i.ToolResults,
&i.Parts,
&i.CreatedAt,
&i.UpdatedAt,
)
@ -108,7 +90,7 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) {
}
const listMessagesBySession = `-- name: ListMessagesBySession :many
SELECT id, session_id, role, content, thinking, finished, tool_calls, tool_results, created_at, updated_at
SELECT id, session_id, role, parts, created_at, updated_at
FROM messages
WHERE session_id = ?
ORDER BY created_at ASC
@ -127,11 +109,7 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
&i.ID,
&i.SessionID,
&i.Role,
&i.Content,
&i.Thinking,
&i.Finished,
&i.ToolCalls,
&i.ToolResults,
&i.Parts,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@ -151,32 +129,17 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) (
const updateMessage = `-- name: UpdateMessage :exec
UPDATE messages
SET
content = ?,
thinking = ?,
tool_calls = ?,
tool_results = ?,
finished = ?,
parts = ?,
updated_at = strftime('%s', 'now')
WHERE id = ?
`
type UpdateMessageParams struct {
Content string `json:"content"`
Thinking string `json:"thinking"`
ToolCalls sql.NullString `json:"tool_calls"`
ToolResults sql.NullString `json:"tool_results"`
Finished bool `json:"finished"`
ID string `json:"id"`
Parts string `json:"parts"`
ID string `json:"id"`
}
func (q *Queries) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error {
_, err := q.exec(ctx, q.updateMessageStmt, updateMessage,
arg.Content,
arg.Thinking,
arg.ToolCalls,
arg.ToolResults,
arg.Finished,
arg.ID,
)
_, err := q.exec(ctx, q.updateMessageStmt, updateMessage, arg.Parts, arg.ID)
return err
}

View file

@ -23,11 +23,7 @@ CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
thinking Text NOT NULL DEFAULT '',
finished BOOLEAN NOT NULL DEFAULT 0,
tool_calls TEXT,
tool_results TEXT,
parts TEXT NOT NULL default '[]',
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

View file

@ -9,16 +9,12 @@ import (
)
type Message struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
Thinking string `json:"thinking"`
Finished bool `json:"finished"`
ToolCalls sql.NullString `json:"tool_calls"`
ToolResults sql.NullString `json:"tool_results"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID string `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Parts string `json:"parts"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type Session struct {

View file

@ -14,25 +14,18 @@ INSERT INTO messages (
id,
session_id,
role,
finished,
content,
tool_calls,
tool_results,
parts,
created_at,
updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
?, ?, ?, ?, strftime('%s', 'now'), strftime('%s', 'now')
)
RETURNING *;
-- name: UpdateMessage :exec
UPDATE messages
SET
content = ?,
thinking = ?,
tool_calls = ?,
tool_results = ?,
finished = ?,
parts = ?,
updated_at = strftime('%s', 'now')
WHERE id = ?;

View file

@ -91,7 +91,7 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
if err != nil {
return tools.NewTextErrorResponse(fmt.Sprintf("error: %s", err)), nil
}
return tools.NewTextResponse(response.Content), nil
return tools.NewTextResponse(response.Content().String()), nil
}
func NewAgentTool(parentSessionID string, app *app.App) tools.BaseTool {

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log"
"strings"
"sync"
"github.com/kujtimiihoxha/termai/internal/app"
@ -33,8 +34,12 @@ func (c *agent) handleTitleGeneration(sessionID, content string) {
c.Context,
[]message.Message{
{
Role: message.User,
Content: content,
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{
Text: content,
},
},
},
},
nil,
@ -49,6 +54,8 @@ func (c *agent) handleTitleGeneration(sessionID, content string) {
}
if response.Content != "" {
session.Title = response.Content
session.Title = strings.TrimSpace(session.Title)
session.Title = strings.ReplaceAll(session.Title, "\n", " ")
c.Sessions.Save(session)
}
}
@ -79,17 +86,18 @@ func (c *agent) processEvent(
) error {
switch event.Type {
case provider.EventThinkingDelta:
assistantMsg.Thinking += event.Thinking
assistantMsg.AppendReasoningContent(event.Content)
return c.Messages.Update(*assistantMsg)
case provider.EventContentDelta:
assistantMsg.Content += event.Content
assistantMsg.AppendContent(event.Content)
return c.Messages.Update(*assistantMsg)
case provider.EventError:
log.Println("error", event.Error)
return event.Error
case provider.EventComplete:
assistantMsg.ToolCalls = event.Response.ToolCalls
assistantMsg.SetToolCalls(event.Response.ToolCalls)
assistantMsg.AddFinish(event.Response.FinishReason)
err := c.Messages.Update(*assistantMsg)
if err != nil {
return err
@ -157,18 +165,21 @@ func (c *agent) handleToolExecution(
ctx context.Context,
assistantMsg message.Message,
) (*message.Message, error) {
if len(assistantMsg.ToolCalls) == 0 {
if len(assistantMsg.ToolCalls()) == 0 {
return nil, nil
}
toolResults, err := c.ExecuteTools(ctx, assistantMsg.ToolCalls, c.tools)
toolResults, err := c.ExecuteTools(ctx, assistantMsg.ToolCalls(), c.tools)
if err != nil {
return nil, err
}
parts := make([]message.ContentPart, 0)
for _, toolResult := range toolResults {
parts = append(parts, toolResult)
}
msg, err := c.Messages.Create(assistantMsg.SessionID, message.CreateMessageParams{
Role: message.Tool,
ToolResults: toolResults,
Role: message.Tool,
Parts: parts,
})
return &msg, err
@ -185,8 +196,12 @@ func (c *agent) generate(sessionID string, content string) error {
}
userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
Role: message.User,
Content: content,
Role: message.User,
Parts: []message.ContentPart{
message.TextContent{
Text: content,
},
},
})
if err != nil {
return err
@ -201,8 +216,8 @@ func (c *agent) generate(sessionID string, content string) error {
}
assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
Role: message.Assistant,
Content: "",
Role: message.Assistant,
Parts: []message.ContentPart{},
})
if err != nil {
return err
@ -210,20 +225,20 @@ func (c *agent) generate(sessionID string, content string) error {
for event := range eventChan {
err = c.processEvent(sessionID, &assistantMsg, event)
if err != nil {
assistantMsg.Finished = true
assistantMsg.AddFinish("error:" + err.Error())
c.Messages.Update(assistantMsg)
return err
}
}
msg, err := c.handleToolExecution(c.Context, assistantMsg)
assistantMsg.Finished = true
c.Messages.Update(assistantMsg)
if err != nil {
return err
}
if len(assistantMsg.ToolCalls) == 0 {
if len(assistantMsg.ToolCalls()) == 0 {
break
}

View file

@ -44,20 +44,23 @@ func NewCoderAgent(app *app.App) (Agent, error) {
return nil, err
}
mcpTools := GetMcpTools(app.Context)
otherTools := GetMcpTools(app.Context)
if len(app.LSPClients) > 0 {
otherTools = append(otherTools, tools.NewDiagnosticsTool(app.LSPClients))
}
return &coderAgent{
agent: &agent{
App: app,
tools: append(
[]tools.BaseTool{
tools.NewBashTool(),
tools.NewEditTool(),
tools.NewEditTool(app.LSPClients),
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewViewTool(),
tools.NewWriteTool(),
}, mcpTools...,
tools.NewViewTool(app.LSPClients),
tools.NewWriteTool(app.LSPClients),
}, otherTools...,
),
model: model,
agent: agentProvider,

View file

@ -34,7 +34,7 @@ func NewTaskAgent(app *app.App) (Agent, error) {
tools.NewGlobTool(),
tools.NewGrepTool(),
tools.NewLsTool(),
tools.NewViewTool(),
tools.NewViewTool(app.LSPClients),
},
model: model,
agent: agentProvider,

View file

@ -67,7 +67,7 @@ Never commit changes unless the user explicitly asks you to.`
envInfo := getEnvironmentInfo()
return fmt.Sprintf("%s\n\n%s", basePrompt, envInfo)
return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
}
func CoderAnthropicSystemPrompt() string {
@ -168,7 +168,7 @@ You MUST answer concisely with fewer than 4 lines of text (not including tool us
envInfo := getEnvironmentInfo()
return fmt.Sprintf("%s\n\n%s", basePrompt, envInfo)
return fmt.Sprintf("%s\n\n%s\n%s", basePrompt, envInfo, lspInformation())
}
func getEnvironmentInfo() string {
@ -198,6 +198,25 @@ func isGitRepo(dir string) bool {
return err == nil
}
func lspInformation() string {
cfg := config.Get()
hasLSP := false
for _, v := range cfg.LSP {
if !v.Disabled {
hasLSP = true
break
}
}
if !hasLSP {
return ""
}
return `# LSP Information
Tools that support it will also include useful diagnostics such as linting and typechecking.
These diagnostics will be automatically enabled when you run the tool, and will be displayed in the output at the bottom within the <file_diagnostics></file_diagnostics> and <project_diagnostics></project_diagnostics> tags.
Take necessary actions to fix the issues.
`
}
func boolToYesNo(b bool) string {
if b {
return "Yes"

View file

@ -111,7 +111,7 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
var thinkingParam anthropic.ThinkingConfigParamUnion
lastMessage := messages[len(messages)-1]
temperature := anthropic.Float(0)
if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content), "think") {
if lastMessage.Role == message.User && strings.Contains(strings.ToLower(lastMessage.Content().String()), "think") {
thinkingParam = anthropic.ThinkingConfigParamUnion{
OfThinkingConfigEnabled: &anthropic.ThinkingConfigEnabledParam{
BudgetTokens: int64(float64(a.maxTokens) * 0.8),
@ -187,9 +187,10 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: content,
ToolCalls: toolCalls,
Usage: tokenUsage,
Content: content,
ToolCalls: toolCalls,
Usage: tokenUsage,
FinishReason: string(accumulatedMessage.StopReason),
},
}
}
@ -263,7 +264,7 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
for i, msg := range messages {
switch msg.Role {
case message.User:
content := anthropic.NewTextBlock(msg.Content)
content := anthropic.NewTextBlock(msg.Content().String())
if cachedBlocks < 2 {
content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
@ -274,8 +275,8 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
case message.Assistant:
blocks := []anthropic.ContentBlockParamUnion{}
if msg.Content != "" {
content := anthropic.NewTextBlock(msg.Content)
if msg.Content().String() != "" {
content := anthropic.NewTextBlock(msg.Content().String())
if cachedBlocks < 2 {
content.OfRequestTextBlock.CacheControl = anthropic.CacheControlEphemeralParam{
Type: "ephemeral",
@ -285,7 +286,7 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
blocks = append(blocks, content)
}
for _, toolCall := range msg.ToolCalls {
for _, toolCall := range msg.ToolCalls() {
var inputMap map[string]any
err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
if err != nil {
@ -297,8 +298,8 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
anthropicMessages[i] = anthropic.NewAssistantMessage(blocks...)
case message.Tool:
results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults))
for i, toolResult := range msg.ToolResults {
results := make([]anthropic.ContentBlockParamUnion, len(msg.ToolResults()))
for i, toolResult := range msg.ToolResults() {
results[i] = anthropic.NewToolResultBlock(toolResult.ToolCallID, toolResult.Content, toolResult.IsError)
}
anthropicMessages[i] = anthropic.NewUserMessage(results...)

View file

@ -78,7 +78,6 @@ func (p *geminiProvider) Close() {
}
}
// convertToGeminiHistory converts the message history to Gemini's format
func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*genai.Content {
var history []*genai.Content
@ -86,7 +85,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
switch msg.Role {
case message.User:
history = append(history, &genai.Content{
Parts: []genai.Part{genai.Text(msg.Content)},
Parts: []genai.Part{genai.Text(msg.Content().String())},
Role: "user",
})
case message.Assistant:
@ -95,14 +94,12 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
Parts: []genai.Part{},
}
// Handle regular content
if msg.Content != "" {
content.Parts = append(content.Parts, genai.Text(msg.Content))
if msg.Content().String() != "" {
content.Parts = append(content.Parts, genai.Text(msg.Content().String()))
}
// Handle tool calls if any
if len(msg.ToolCalls) > 0 {
for _, call := range msg.ToolCalls {
if len(msg.ToolCalls()) > 0 {
for _, call := range msg.ToolCalls() {
args, _ := parseJsonToMap(call.Input)
content.Parts = append(content.Parts, genai.FunctionCall{
Name: call.Name,
@ -113,8 +110,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
history = append(history, content)
case message.Tool:
for _, result := range msg.ToolResults {
// Parse response content to map if possible
for _, result := range msg.ToolResults() {
response := map[string]interface{}{"result": result.Content}
parsed, err := parseJsonToMap(result.Content)
if err == nil {
@ -123,7 +119,7 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
var toolCall message.ToolCall
for _, msg := range messages {
if msg.Role == message.Assistant {
for _, call := range msg.ToolCalls {
for _, call := range msg.ToolCalls() {
if call.ID == result.ToolCallID {
toolCall = call
break
@ -146,108 +142,6 @@ func (p *geminiProvider) convertToGeminiHistory(messages []message.Message) []*g
return history
}
// convertToolsToGeminiFunctionDeclarations converts tool definitions to Gemini's function declarations
func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
declarations := make([]*genai.FunctionDeclaration, len(tools))
for i, tool := range tools {
info := tool.Info()
// Convert parameters to genai.Schema format
properties := make(map[string]*genai.Schema)
for name, param := range info.Parameters {
// Try to extract type and description from the parameter
paramMap, ok := param.(map[string]interface{})
if !ok {
// Default to string if unable to determine type
properties[name] = &genai.Schema{Type: genai.TypeString}
continue
}
schemaType := genai.TypeString // Default
var description string
var itemsTypeSchema *genai.Schema
if typeVal, found := paramMap["type"]; found {
if typeStr, ok := typeVal.(string); ok {
switch typeStr {
case "string":
schemaType = genai.TypeString
case "number":
schemaType = genai.TypeNumber
case "integer":
schemaType = genai.TypeInteger
case "boolean":
schemaType = genai.TypeBoolean
case "array":
schemaType = genai.TypeArray
items, found := paramMap["items"]
if found {
itemsMap, ok := items.(map[string]interface{})
if ok {
itemsType, found := itemsMap["type"]
if found {
itemsTypeStr, ok := itemsType.(string)
if ok {
switch itemsTypeStr {
case "string":
itemsTypeSchema = &genai.Schema{
Type: genai.TypeString,
}
case "number":
itemsTypeSchema = &genai.Schema{
Type: genai.TypeNumber,
}
case "integer":
itemsTypeSchema = &genai.Schema{
Type: genai.TypeInteger,
}
case "boolean":
itemsTypeSchema = &genai.Schema{
Type: genai.TypeBoolean,
}
}
}
}
}
}
case "object":
schemaType = genai.TypeObject
if _, found := paramMap["properties"]; !found {
continue
}
// TODO: Add support for other types
}
}
}
if desc, found := paramMap["description"]; found {
if descStr, ok := desc.(string); ok {
description = descStr
}
}
properties[name] = &genai.Schema{
Type: schemaType,
Description: description,
Items: itemsTypeSchema,
}
}
declarations[i] = &genai.FunctionDeclaration{
Name: info.Name,
Description: info.Description,
Parameters: &genai.Schema{
Type: genai.TypeObject,
Properties: properties,
Required: info.Required,
},
}
}
return declarations
}
// extractTokenUsage extracts token usage information from Gemini's response
func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse) TokenUsage {
if resp == nil || resp.UsageMetadata == nil {
return TokenUsage{}
@ -261,41 +155,28 @@ func (p *geminiProvider) extractTokenUsage(resp *genai.GenerateContentResponse)
}
}
// SendMessages sends a batch of messages to Gemini and returns the response
func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error) {
// Create a generative model
model := p.client.GenerativeModel(p.model.APIModel)
model.SetMaxOutputTokens(p.maxTokens)
// Set system instruction
model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
// Set up tools if provided
if len(tools) > 0 {
declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
model.Tools = []*genai.Tool{{FunctionDeclarations: declarations}}
}
// Create chat session and set history
chat := model.StartChat()
chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
// Get the most recent user message
var lastUserMsg message.Message
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == message.User {
lastUserMsg = messages[i]
break
for _, declaration := range declarations {
model.Tools = append(model.Tools, &genai.Tool{FunctionDeclarations: []*genai.FunctionDeclaration{declaration}})
}
}
// Send the message
resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content))
chat := model.StartChat()
chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
lastUserMsg := messages[len(messages)-1]
resp, err := chat.SendMessage(ctx, genai.Text(lastUserMsg.Content().String()))
if err != nil {
return nil, err
}
// Process the response
var content string
var toolCalls []message.ToolCall
@ -317,7 +198,6 @@ func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Me
}
}
// Extract token usage
tokenUsage := p.extractTokenUsage(resp)
return &ProviderResponse{
@ -327,16 +207,12 @@ func (p *geminiProvider) SendMessages(ctx context.Context, messages []message.Me
}, nil
}
// StreamResponse streams the response from Gemini
func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (<-chan ProviderEvent, error) {
// Create a generative model
model := p.client.GenerativeModel(p.model.APIModel)
model.SetMaxOutputTokens(p.maxTokens)
// Set system instruction
model.SystemInstruction = genai.NewUserContent(genai.Text(p.systemMessage))
// Set up tools if provided
if len(tools) > 0 {
declarations := p.convertToolsToGeminiFunctionDeclarations(tools)
for _, declaration := range declarations {
@ -344,14 +220,12 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
}
}
// Create chat session and set history
chat := model.StartChat()
chat.History = p.convertToGeminiHistory(messages[:len(messages)-1]) // Exclude last message
lastUserMsg := messages[len(messages)-1]
// Start streaming
iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content))
iter := chat.SendMessageStream(ctx, genai.Text(lastUserMsg.Content().String()))
eventChan := make(chan ProviderEvent)
@ -392,7 +266,6 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
}
currentContent += newText
case genai.FunctionCall:
// For function calls, we assume they come complete, not streamed in parts
id := "call_" + uuid.New().String()
args, _ := json.Marshal(p.Args)
newCall := message.ToolCall{
@ -402,7 +275,6 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
Type: "function",
}
// Check if this is a new tool call
isNew := true
for _, existing := range toolCalls {
if existing.Name == newCall.Name && existing.Input == newCall.Input {
@ -419,15 +291,15 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
}
}
// Extract token usage from the final response
tokenUsage := p.extractTokenUsage(finalResp)
eventChan <- ProviderEvent{
Type: EventComplete,
Response: &ProviderResponse{
Content: currentContent,
ToolCalls: toolCalls,
Usage: tokenUsage,
Content: currentContent,
ToolCalls: toolCalls,
Usage: tokenUsage,
FinishReason: string(finalResp.Candidates[0].FinishReason.String()),
},
}
}()
@ -435,7 +307,99 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
return eventChan, nil
}
// Helper function to parse JSON string into map
func (p *geminiProvider) convertToolsToGeminiFunctionDeclarations(tools []tools.BaseTool) []*genai.FunctionDeclaration {
declarations := make([]*genai.FunctionDeclaration, len(tools))
for i, tool := range tools {
info := tool.Info()
declarations[i] = &genai.FunctionDeclaration{
Name: info.Name,
Description: info.Description,
Parameters: &genai.Schema{
Type: genai.TypeObject,
Properties: convertSchemaProperties(info.Parameters),
Required: info.Required,
},
}
}
return declarations
}
func convertSchemaProperties(parameters map[string]interface{}) map[string]*genai.Schema {
properties := make(map[string]*genai.Schema)
for name, param := range parameters {
properties[name] = convertToSchema(param)
}
return properties
}
func convertToSchema(param interface{}) *genai.Schema {
schema := &genai.Schema{Type: genai.TypeString}
paramMap, ok := param.(map[string]interface{})
if !ok {
return schema
}
if desc, ok := paramMap["description"].(string); ok {
schema.Description = desc
}
typeVal, hasType := paramMap["type"]
if !hasType {
return schema
}
typeStr, ok := typeVal.(string)
if !ok {
return schema
}
schema.Type = mapJSONTypeToGenAI(typeStr)
switch typeStr {
case "array":
schema.Items = processArrayItems(paramMap)
case "object":
if props, ok := paramMap["properties"].(map[string]interface{}); ok {
schema.Properties = convertSchemaProperties(props)
}
}
return schema
}
func processArrayItems(paramMap map[string]interface{}) *genai.Schema {
items, ok := paramMap["items"].(map[string]interface{})
if !ok {
return nil
}
return convertToSchema(items)
}
func mapJSONTypeToGenAI(jsonType string) genai.Type {
switch jsonType {
case "string":
return genai.TypeString
case "number":
return genai.TypeNumber
case "integer":
return genai.TypeInteger
case "boolean":
return genai.TypeBoolean
case "array":
return genai.TypeArray
case "object":
return genai.TypeObject
default:
return genai.TypeString // Default to string for unknown types
}
}
func parseJsonToMap(jsonStr string) (map[string]interface{}, error) {
var result map[string]interface{}
err := json.Unmarshal([]byte(jsonStr), &result)

View file

@ -84,22 +84,22 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
for _, msg := range messages {
switch msg.Role {
case message.User:
chatMessages = append(chatMessages, openai.UserMessage(msg.Content))
chatMessages = append(chatMessages, openai.UserMessage(msg.Content().String()))
case message.Assistant:
assistantMsg := openai.ChatCompletionAssistantMessageParam{
Role: "assistant",
}
if msg.Content != "" {
if msg.Content().String() != "" {
assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
OfString: openai.String(msg.Content),
OfString: openai.String(msg.Content().String()),
}
}
if len(msg.ToolCalls) > 0 {
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls))
for i, call := range msg.ToolCalls {
if len(msg.ToolCalls()) > 0 {
assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
for i, call := range msg.ToolCalls() {
assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
ID: call.ID,
Type: "function",
@ -116,7 +116,7 @@ func (p *openaiProvider) convertToOpenAIMessages(messages []message.Message) []o
})
case message.Tool:
for _, result := range msg.ToolResults {
for _, result := range msg.ToolResults() {
chatMessages = append(chatMessages,
openai.ToolMessage(result.Content, result.ToolCallID),
)
@ -276,3 +276,4 @@ func (p *openaiProvider) StreamResponse(ctx context.Context, messages []message.
return eventChan, nil
}

View file

@ -27,9 +27,10 @@ type TokenUsage struct {
}
type ProviderResponse struct {
Content string
ToolCalls []message.ToolCall
Usage TokenUsage
Content string
ToolCalls []message.ToolCall
Usage TokenUsage
FinishReason string
}
type ProviderEvent struct {

View file

@ -0,0 +1,229 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
type diagnosticsTool struct {
lspClients map[string]*lsp.Client
}
const (
DiagnosticsToolName = "diagnostics"
)
type DiagnosticsParams struct {
FilePath string `json:"file_path"`
}
func (b *diagnosticsTool) Info() ToolInfo {
return ToolInfo{
Name: DiagnosticsToolName,
Description: "Get diagnostics for a file and/or project.",
Parameters: map[string]any{
"file_path": map[string]any{
"type": "string",
"description": "The path to the file to get diagnostics for (leave w empty for project diagnostics)",
},
},
Required: []string{},
}
}
func (b *diagnosticsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
var params DiagnosticsParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
}
lsps := b.lspClients
if len(lsps) == 0 {
return NewTextErrorResponse("no LSP clients available"), nil
}
if params.FilePath == "" {
notifyLspOpenFile(ctx, params.FilePath, lsps)
}
output := appendDiagnostics(params.FilePath, lsps)
return NewTextResponse(output), nil
}
func notifyLspOpenFile(ctx context.Context, filePath string, lsps map[string]*lsp.Client) {
for _, client := range lsps {
err := client.OpenFile(ctx, filePath)
if err != nil {
// Wait for the file to be opened and diagnostics to be received
// TODO: see if we can do this in a more efficient way
time.Sleep(2 * time.Second)
}
}
}
func appendDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
fileDiagnostics := []string{}
projectDiagnostics := []string{}
// Enhanced format function that includes more diagnostic information
formatDiagnostic := func(pth string, diagnostic protocol.Diagnostic, source string) string {
// Base components
severity := "Info"
switch diagnostic.Severity {
case protocol.SeverityError:
severity = "Error"
case protocol.SeverityWarning:
severity = "Warn"
case protocol.SeverityHint:
severity = "Hint"
}
// Location information
location := fmt.Sprintf("%s:%d:%d", pth, diagnostic.Range.Start.Line+1, diagnostic.Range.Start.Character+1)
// Source information (LSP name)
sourceInfo := ""
if diagnostic.Source != "" {
sourceInfo = diagnostic.Source
} else if source != "" {
sourceInfo = source
}
// Code information
codeInfo := ""
if diagnostic.Code != nil {
codeInfo = fmt.Sprintf("[%v]", diagnostic.Code)
}
// Tags information
tagsInfo := ""
if len(diagnostic.Tags) > 0 {
tags := []string{}
for _, tag := range diagnostic.Tags {
switch tag {
case protocol.Unnecessary:
tags = append(tags, "unnecessary")
case protocol.Deprecated:
tags = append(tags, "deprecated")
}
}
if len(tags) > 0 {
tagsInfo = fmt.Sprintf(" (%s)", strings.Join(tags, ", "))
}
}
// Assemble the full diagnostic message
return fmt.Sprintf("%s: %s [%s]%s%s %s",
severity,
location,
sourceInfo,
codeInfo,
tagsInfo,
diagnostic.Message)
}
for lspName, client := range lsps {
diagnostics := client.GetDiagnostics()
if len(diagnostics) > 0 {
for location, diags := range diagnostics {
isCurrentFile := location.Path() == filePath
// Group diagnostics by severity for better organization
for _, diag := range diags {
formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
if isCurrentFile {
fileDiagnostics = append(fileDiagnostics, formattedDiag)
} else {
projectDiagnostics = append(projectDiagnostics, formattedDiag)
}
}
}
}
}
// Sort diagnostics by severity (errors first) and then by location
sort.Slice(fileDiagnostics, func(i, j int) bool {
iIsError := strings.HasPrefix(fileDiagnostics[i], "Error")
jIsError := strings.HasPrefix(fileDiagnostics[j], "Error")
if iIsError != jIsError {
return iIsError // Errors come first
}
return fileDiagnostics[i] < fileDiagnostics[j] // Then alphabetically
})
sort.Slice(projectDiagnostics, func(i, j int) bool {
iIsError := strings.HasPrefix(projectDiagnostics[i], "Error")
jIsError := strings.HasPrefix(projectDiagnostics[j], "Error")
if iIsError != jIsError {
return iIsError
}
return projectDiagnostics[i] < projectDiagnostics[j]
})
output := ""
if len(fileDiagnostics) > 0 {
output += "\n<file_diagnostics>\n"
if len(fileDiagnostics) > 10 {
output += strings.Join(fileDiagnostics[:10], "\n")
output += fmt.Sprintf("\n... and %d more diagnostics", len(fileDiagnostics)-10)
} else {
output += strings.Join(fileDiagnostics, "\n")
}
output += "\n</file_diagnostics>\n"
}
if len(projectDiagnostics) > 0 {
output += "\n<project_diagnostics>\n"
if len(projectDiagnostics) > 10 {
output += strings.Join(projectDiagnostics[:10], "\n")
output += fmt.Sprintf("\n... and %d more diagnostics", len(projectDiagnostics)-10)
} else {
output += strings.Join(projectDiagnostics, "\n")
}
output += "\n</project_diagnostics>\n"
}
// Add summary counts
if len(fileDiagnostics) > 0 || len(projectDiagnostics) > 0 {
fileErrors := countSeverity(fileDiagnostics, "Error")
fileWarnings := countSeverity(fileDiagnostics, "Warn")
projectErrors := countSeverity(projectDiagnostics, "Error")
projectWarnings := countSeverity(projectDiagnostics, "Warn")
output += "\n<diagnostic_summary>\n"
output += fmt.Sprintf("Current file: %d errors, %d warnings\n", fileErrors, fileWarnings)
output += fmt.Sprintf("Project: %d errors, %d warnings\n", projectErrors, projectWarnings)
output += "</diagnostic_summary>\n"
}
return output
}
// Helper function to count diagnostics by severity
func countSeverity(diagnostics []string, severity string) int {
count := 0
for _, diag := range diagnostics {
if strings.HasPrefix(diag, severity) {
count++
}
}
return count
}
func NewDiagnosticsTool(lspClients map[string]*lsp.Client) BaseTool {
return &diagnosticsTool{
lspClients,
}
}

View file

@ -10,11 +10,14 @@ import (
"time"
"github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/sergi/go-diff/diffmatchpatch"
)
type editTool struct{}
type editTool struct {
lspClients map[string]*lsp.Client
}
const (
EditToolName = "edit"
@ -71,6 +74,7 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
params.FilePath = filepath.Join(wd, params.FilePath)
}
notifyLspOpenFile(ctx, params.FilePath, e.lspClients)
if params.OldString == "" {
result, err := createNewFile(params.FilePath, params.NewString)
if err != nil {
@ -91,6 +95,9 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
if err != nil {
return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
}
result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
result += appendDiagnostics(params.FilePath, e.lspClients)
return NewTextResponse(result), nil
}
@ -296,18 +303,18 @@ func GenerateDiff(oldContent, newContent string) string {
switch diff.Type {
case diffmatchpatch.DiffInsert:
for _, line := range strings.Split(text, "\n") {
for line := range strings.SplitSeq(text, "\n") {
_, _ = buff.WriteString("+ " + line + "\n")
}
case diffmatchpatch.DiffDelete:
for _, line := range strings.Split(text, "\n") {
for line := range strings.SplitSeq(text, "\n") {
_, _ = buff.WriteString("- " + line + "\n")
}
case diffmatchpatch.DiffEqual:
if len(text) > 40 {
_, _ = buff.WriteString(" " + text[:20] + "..." + text[len(text)-20:] + "\n")
} else {
for _, line := range strings.Split(text, "\n") {
for line := range strings.SplitSeq(text, "\n") {
_, _ = buff.WriteString(" " + line + "\n")
}
}
@ -366,6 +373,8 @@ When making edits:
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
}
func NewEditTool() BaseTool {
return &editTool{}
func NewEditTool(lspClients map[string]*lsp.Client) BaseTool {
return &editTool{
lspClients,
}
}

View file

@ -221,7 +221,7 @@ func (s *PersistentShell) killChildren() {
return
}
for _, pidStr := range strings.Split(string(output), "\n") {
for pidStr := range strings.SplitSeq(string(output), "\n") {
if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
var pid int
fmt.Sscanf(pidStr, "%d", &pid)

View file

@ -11,9 +11,12 @@ import (
"strings"
"github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/lsp"
)
type viewTool struct{}
type viewTool struct {
lspClients map[string]*lsp.Client
}
const (
ViewToolName = "view"
@ -127,15 +130,18 @@ func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
return NewTextErrorResponse(fmt.Sprintf("Failed to read file: %s", err)), nil
}
notifyLspOpenFile(ctx, filePath, v.lspClients)
output := "<file>\n"
// Format the output with line numbers
output := addLineNumbers(content, params.Offset+1)
output += addLineNumbers(content, params.Offset+1)
// Add a note if the content was truncated
if lineCount > params.Offset+len(strings.Split(content, "\n")) {
output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
params.Offset+len(strings.Split(content, "\n")))
}
output += "\n</file>\n"
output += appendDiagnostics(filePath, v.lspClients)
recordFileRead(filePath)
return NewTextResponse(output), nil
}
@ -155,10 +161,10 @@ func addLineNumbers(content string, startLine int) string {
numStr := fmt.Sprintf("%d", lineNum)
if len(numStr) >= 6 {
result = append(result, fmt.Sprintf("%s\t%s", numStr, line))
result = append(result, fmt.Sprintf("%s|%s", numStr, line))
} else {
paddedNum := fmt.Sprintf("%6s", numStr)
result = append(result, fmt.Sprintf("%s\t|%s", paddedNum, line))
result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
}
}
@ -173,8 +179,9 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
defer file.Close()
lineCount := 0
scanner := NewLineScanner(file)
if offset > 0 {
scanner := NewLineScanner(file)
for lineCount < offset && scanner.Scan() {
lineCount++
}
@ -192,7 +199,6 @@ func readTextFile(filePath string, offset, limit int) (string, int, error) {
var lines []string
lineCount = offset
scanner := NewLineScanner(file)
for scanner.Scan() && len(lines) < limit {
lineCount++
@ -290,6 +296,8 @@ TIPS:
- When viewing large files, use the offset parameter to read specific sections`
}
func NewViewTool() BaseTool {
return &viewTool{}
func NewViewTool(lspClients map[string]*lsp.Client) BaseTool {
return &viewTool{
lspClients,
}
}

View file

@ -9,10 +9,13 @@ import (
"time"
"github.com/kujtimiihoxha/termai/internal/config"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/permission"
)
type writeTool struct{}
type writeTool struct {
lspClients map[string]*lsp.Client
}
const (
WriteToolName = "write"
@ -96,6 +99,8 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
if err = os.MkdirAll(dir, 0o755); err != nil {
return NewTextErrorResponse(fmt.Sprintf("Failed to create parent directories: %s", err)), nil
}
notifyLspOpenFile(ctx, filePath, w.lspClients)
p := permission.Default.Request(
permission.CreatePermissionRequest{
Path: filePath,
@ -122,7 +127,10 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
recordFileWrite(filePath)
recordFileRead(filePath)
return NewTextResponse(fmt.Sprintf("File successfully written: %s", filePath)), nil
result := fmt.Sprintf("File successfully written: %s", filePath)
result = fmt.Sprintf("<result>\n%s\n</result>", result)
result += appendDiagnostics(filePath, w.lspClients)
return NewTextResponse(result), nil
}
func writeDescription() string {
@ -156,6 +164,8 @@ TIPS:
- Always include descriptive comments when making changes to existing code`
}
func NewWriteTool() BaseTool {
return &writeTool{}
func NewWriteTool(lspClients map[string]*lsp.Client) BaseTool {
return &writeTool{
lspClients,
}
}

View file

@ -8,13 +8,14 @@ import (
"testing"
"time"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/permission"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteTool_Info(t *testing.T) {
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
info := tool.Info()
assert.Equal(t, WriteToolName, info.Name)
@ -40,11 +41,11 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("creates a new file successfully", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
filePath := filepath.Join(tempDir, "new_file.txt")
content := "This is a test content"
params := WriteParams{
FilePath: filePath,
Content: content,
@ -70,11 +71,11 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("creates file with nested directories", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
content := "Content in nested directory"
params := WriteParams{
FilePath: filePath,
Content: content,
@ -100,17 +101,17 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("updates existing file", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
// Create a file first
filePath := filepath.Join(tempDir, "existing_file.txt")
initialContent := "Initial content"
err := os.WriteFile(filePath, []byte(initialContent), 0644)
err := os.WriteFile(filePath, []byte(initialContent), 0o644)
require.NoError(t, err)
// Record the file read to avoid modification time check failure
recordFileRead(filePath)
// Update the file
updatedContent := "Updated content"
params := WriteParams{
@ -138,8 +139,8 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("handles invalid parameters", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
call := ToolCall{
Name: WriteToolName,
Input: "invalid json",
@ -152,8 +153,8 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("handles missing file_path", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
params := WriteParams{
FilePath: "",
Content: "Some content",
@ -174,8 +175,8 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("handles missing content", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
params := WriteParams{
FilePath: filepath.Join(tempDir, "file.txt"),
Content: "",
@ -196,13 +197,13 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("handles writing to a directory path", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
// Create a directory
dirPath := filepath.Join(tempDir, "test_dir")
err := os.Mkdir(dirPath, 0755)
err := os.Mkdir(dirPath, 0o755)
require.NoError(t, err)
params := WriteParams{
FilePath: dirPath,
Content: "Some content",
@ -223,8 +224,8 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("handles permission denied", func(t *testing.T) {
permission.Default = newMockPermissionService(false)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
filePath := filepath.Join(tempDir, "permission_denied.txt")
params := WriteParams{
FilePath: filePath,
@ -242,7 +243,7 @@ func TestWriteTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
assert.Contains(t, response.Content, "Permission denied")
// Verify file was not created
_, err = os.Stat(filePath)
assert.True(t, os.IsNotExist(err))
@ -250,14 +251,14 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("detects file modified since last read", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
// Create a file
filePath := filepath.Join(tempDir, "modified_file.txt")
initialContent := "Initial content"
err := os.WriteFile(filePath, []byte(initialContent), 0644)
err := os.WriteFile(filePath, []byte(initialContent), 0o644)
require.NoError(t, err)
// Record an old read time
fileRecordMutex.Lock()
fileRecords[filePath] = fileRecord{
@ -265,7 +266,7 @@ func TestWriteTool_Run(t *testing.T) {
readTime: time.Now().Add(-1 * time.Hour),
}
fileRecordMutex.Unlock()
// Try to update the file
params := WriteParams{
FilePath: filePath,
@ -283,7 +284,7 @@ func TestWriteTool_Run(t *testing.T) {
response, err := tool.Run(context.Background(), call)
require.NoError(t, err)
assert.Contains(t, response.Content, "has been modified since it was last read")
// Verify file was not modified
fileContent, err := os.ReadFile(filePath)
require.NoError(t, err)
@ -292,17 +293,17 @@ func TestWriteTool_Run(t *testing.T) {
t.Run("skips writing when content is identical", func(t *testing.T) {
permission.Default = newMockPermissionService(true)
tool := NewWriteTool()
tool := NewWriteTool(make(map[string]*lsp.Client))
// Create a file
filePath := filepath.Join(tempDir, "identical_content.txt")
content := "Content that won't change"
err := os.WriteFile(filePath, []byte(content), 0644)
err := os.WriteFile(filePath, []byte(content), 0o644)
require.NoError(t, err)
// Record a read time
recordFileRead(filePath)
// Try to write the same content
params := WriteParams{
FilePath: filePath,
@ -321,4 +322,5 @@ func TestWriteTool_Run(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, response.Content, "already contains the exact content")
})
}
}

429
internal/lsp/client.go Normal file
View file

@ -0,0 +1,429 @@
package lsp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
type Client struct {
Cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
stderr io.ReadCloser
// Request ID counter
nextID atomic.Int32
// Response handlers
handlers map[int32]chan *Message
handlersMu sync.RWMutex
// Server request handlers
serverRequestHandlers map[string]ServerRequestHandler
serverHandlersMu sync.RWMutex
// Notification handlers
notificationHandlers map[string]NotificationHandler
notificationMu sync.RWMutex
// Diagnostic cache
diagnostics map[protocol.DocumentUri][]protocol.Diagnostic
diagnosticsMu sync.RWMutex
// Files are currently opened by the LSP
openFiles map[string]*OpenFileInfo
openFilesMu sync.RWMutex
}
func NewClient(command string, args ...string) (*Client, error) {
cmd := exec.Command(command, args...)
// Copy env
cmd.Env = os.Environ()
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
}
client := &Client{
Cmd: cmd,
stdin: stdin,
stdout: bufio.NewReader(stdout),
stderr: stderr,
handlers: make(map[int32]chan *Message),
notificationHandlers: make(map[string]NotificationHandler),
serverRequestHandlers: make(map[string]ServerRequestHandler),
diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic),
openFiles: make(map[string]*OpenFileInfo),
}
// Start the LSP server process
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start LSP server: %w", err)
}
// Handle stderr in a separate goroutine
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
}
}()
// Start message handling loop
go client.handleMessages()
return client, nil
}
func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) {
c.notificationMu.Lock()
defer c.notificationMu.Unlock()
c.notificationHandlers[method] = handler
}
func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) {
c.serverHandlersMu.Lock()
defer c.serverHandlersMu.Unlock()
c.serverRequestHandlers[method] = handler
}
func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
initParams := &protocol.InitializeParams{
WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
WorkspaceFolders: []protocol.WorkspaceFolder{
{
URI: protocol.URI("file://" + workspaceDir),
Name: workspaceDir,
},
},
},
XInitializeParams: protocol.XInitializeParams{
ProcessID: int32(os.Getpid()),
ClientInfo: &protocol.ClientInfo{
Name: "mcp-language-server",
Version: "0.1.0",
},
RootPath: workspaceDir,
RootURI: protocol.DocumentUri("file://" + workspaceDir),
Capabilities: protocol.ClientCapabilities{
Workspace: protocol.WorkspaceClientCapabilities{
Configuration: true,
DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{
DynamicRegistration: true,
},
DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{
DynamicRegistration: true,
RelativePatternSupport: true,
},
},
TextDocument: protocol.TextDocumentClientCapabilities{
Synchronization: &protocol.TextDocumentSyncClientCapabilities{
DynamicRegistration: true,
DidSave: true,
},
Completion: protocol.CompletionClientCapabilities{
CompletionItem: protocol.ClientCompletionItemOptions{},
},
CodeLens: &protocol.CodeLensClientCapabilities{
DynamicRegistration: true,
},
DocumentSymbol: protocol.DocumentSymbolClientCapabilities{},
CodeAction: protocol.CodeActionClientCapabilities{
CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{
CodeActionKind: protocol.ClientCodeActionKindOptions{
ValueSet: []protocol.CodeActionKind{},
},
},
},
PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{
VersionSupport: true,
},
SemanticTokens: protocol.SemanticTokensClientCapabilities{
Requests: protocol.ClientSemanticTokensRequestOptions{
Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{},
Full: &protocol.Or_ClientSemanticTokensRequestOptions_full{},
},
TokenTypes: []string{},
TokenModifiers: []string{},
Formats: []protocol.TokenFormat{},
},
},
Window: protocol.WindowClientCapabilities{},
},
InitializationOptions: map[string]any{
"codelenses": map[string]bool{
"generate": true,
"regenerate_cgo": true,
"test": true,
"tidy": true,
"upgrade_dependency": true,
"vendor": true,
"vulncheck": false,
},
},
},
}
var result protocol.InitializeResult
if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
return nil, fmt.Errorf("initialize failed: %w", err)
}
if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
return nil, fmt.Errorf("initialized notification failed: %w", err)
}
// Register handlers
c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
c.RegisterNotificationHandler("textDocument/publishDiagnostics",
func(params json.RawMessage) { HandleDiagnostics(c, params) })
// Notify the LSP server
err := c.Initialized(ctx, protocol.InitializedParams{})
if err != nil {
return nil, fmt.Errorf("initialization failed: %w", err)
}
// LSP sepecific Initialization
path := strings.ToLower(c.Cmd.Path)
switch {
case strings.Contains(path, "typescript-language-server"):
// err := initializeTypescriptLanguageServer(ctx, c, workspaceDir)
// if err != nil {
// return nil, err
// }
}
return &result, nil
}
func (c *Client) Close() error {
// Try to close all open files first
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Attempt to close files but continue shutdown regardless
c.CloseAllFiles(ctx)
// Close stdin to signal the server
if err := c.stdin.Close(); err != nil {
return fmt.Errorf("failed to close stdin: %w", err)
}
// Use a channel to handle the Wait with timeout
done := make(chan error, 1)
go func() {
done <- c.Cmd.Wait()
}()
// Wait for process to exit with timeout
select {
case err := <-done:
return err
case <-time.After(2 * time.Second):
// If we timeout, try to kill the process
if err := c.Cmd.Process.Kill(); err != nil {
return fmt.Errorf("failed to kill process: %w", err)
}
return fmt.Errorf("process killed after timeout")
}
}
type ServerState int
const (
StateStarting ServerState = iota
StateReady
StateError
)
func (c *Client) WaitForServerReady(ctx context.Context) error {
// TODO: wait for specific messages or poll workspace/symbol
time.Sleep(time.Second * 1)
return nil
}
type OpenFileInfo struct {
Version int32
URI protocol.DocumentUri
}
func (c *Client) OpenFile(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)
c.openFilesMu.Lock()
if _, exists := c.openFiles[uri]; exists {
c.openFilesMu.Unlock()
return nil // Already open
}
c.openFilesMu.Unlock()
// Skip files that do not exist or cannot be read
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file: %w", err)
}
params := protocol.DidOpenTextDocumentParams{
TextDocument: protocol.TextDocumentItem{
URI: protocol.DocumentUri(uri),
LanguageID: DetectLanguageID(uri),
Version: 1,
Text: string(content),
},
}
if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
return err
}
c.openFilesMu.Lock()
c.openFiles[uri] = &OpenFileInfo{
Version: 1,
URI: protocol.DocumentUri(uri),
}
c.openFilesMu.Unlock()
return nil
}
func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)
content, err := os.ReadFile(filepath)
if err != nil {
return fmt.Errorf("error reading file: %w", err)
}
c.openFilesMu.Lock()
fileInfo, isOpen := c.openFiles[uri]
if !isOpen {
c.openFilesMu.Unlock()
return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
}
// Increment version
fileInfo.Version++
version := fileInfo.Version
c.openFilesMu.Unlock()
params := protocol.DidChangeTextDocumentParams{
TextDocument: protocol.VersionedTextDocumentIdentifier{
TextDocumentIdentifier: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
Version: version,
},
ContentChanges: []protocol.TextDocumentContentChangeEvent{
{
Value: protocol.TextDocumentContentChangeWholeDocument{
Text: string(content),
},
},
},
}
return c.Notify(ctx, "textDocument/didChange", params)
}
func (c *Client) CloseFile(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)
c.openFilesMu.Lock()
if _, exists := c.openFiles[uri]; !exists {
c.openFilesMu.Unlock()
return nil // Already closed
}
c.openFilesMu.Unlock()
params := protocol.DidCloseTextDocumentParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
}
log.Println("Closing", params.TextDocument.URI.Dir())
if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
return err
}
c.openFilesMu.Lock()
delete(c.openFiles, uri)
c.openFilesMu.Unlock()
return nil
}
func (c *Client) IsFileOpen(filepath string) bool {
uri := fmt.Sprintf("file://%s", filepath)
c.openFilesMu.RLock()
defer c.openFilesMu.RUnlock()
_, exists := c.openFiles[uri]
return exists
}
// CloseAllFiles closes all currently open files
func (c *Client) CloseAllFiles(ctx context.Context) {
c.openFilesMu.Lock()
filesToClose := make([]string, 0, len(c.openFiles))
// First collect all URIs that need to be closed
for uri := range c.openFiles {
// Convert URI back to file path by trimming "file://" prefix
filePath := strings.TrimPrefix(uri, "file://")
filesToClose = append(filesToClose, filePath)
}
c.openFilesMu.Unlock()
// Then close them all
for _, filePath := range filesToClose {
err := c.CloseFile(ctx, filePath)
if err != nil && debug {
log.Printf("Error closing file %s: %v", filePath, err)
}
}
if debug {
log.Printf("Closed %d files", len(filesToClose))
}
}
func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnostic {
c.diagnosticsMu.RLock()
defer c.diagnosticsMu.RUnlock()
return c.diagnostics[uri]
}
func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
return c.diagnostics
}

104
internal/lsp/handlers.go Normal file
View file

@ -0,0 +1,104 @@
package lsp
import (
"encoding/json"
"log"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
"github.com/kujtimiihoxha/termai/internal/lsp/util"
)
// Requests
func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
return []map[string]any{{}}, nil
}
func HandleRegisterCapability(params json.RawMessage) (any, error) {
var registerParams protocol.RegistrationParams
if err := json.Unmarshal(params, &registerParams); err != nil {
log.Printf("Error unmarshaling registration params: %v", err)
return nil, err
}
for _, reg := range registerParams.Registrations {
switch reg.Method {
case "workspace/didChangeWatchedFiles":
// Parse the registration options
optionsJSON, err := json.Marshal(reg.RegisterOptions)
if err != nil {
log.Printf("Error marshaling registration options: %v", err)
continue
}
var options protocol.DidChangeWatchedFilesRegistrationOptions
if err := json.Unmarshal(optionsJSON, &options); err != nil {
log.Printf("Error unmarshaling registration options: %v", err)
continue
}
// Store the file watchers registrations
notifyFileWatchRegistration(reg.ID, options.Watchers)
}
}
return nil, nil
}
func HandleApplyEdit(params json.RawMessage) (any, error) {
var edit protocol.ApplyWorkspaceEditParams
if err := json.Unmarshal(params, &edit); err != nil {
return nil, err
}
err := util.ApplyWorkspaceEdit(edit.Edit)
if err != nil {
log.Printf("Error applying workspace edit: %v", err)
return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
}
return protocol.ApplyWorkspaceEditResult{Applied: true}, nil
}
// FileWatchRegistrationHandler is a function that will be called when file watch registrations are received
type FileWatchRegistrationHandler func(id string, watchers []protocol.FileSystemWatcher)
// fileWatchHandler holds the current handler for file watch registrations
var fileWatchHandler FileWatchRegistrationHandler
// RegisterFileWatchHandler sets the handler for file watch registrations
func RegisterFileWatchHandler(handler FileWatchRegistrationHandler) {
fileWatchHandler = handler
}
// notifyFileWatchRegistration notifies the handler about new file watch registrations
func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatcher) {
if fileWatchHandler != nil {
fileWatchHandler(id, watchers)
}
}
// Notifications
func HandleServerMessage(params json.RawMessage) {
var msg struct {
Type int `json:"type"`
Message string `json:"message"`
}
if err := json.Unmarshal(params, &msg); err == nil {
log.Printf("Server message: %s\n", msg.Message)
}
}
func HandleDiagnostics(client *Client, params json.RawMessage) {
var diagParams protocol.PublishDiagnosticsParams
if err := json.Unmarshal(params, &diagParams); err != nil {
log.Printf("Error unmarshaling diagnostic params: %v", err)
return
}
client.diagnosticsMu.Lock()
defer client.diagnosticsMu.Unlock()
client.diagnostics[diagParams.URI] = diagParams.Diagnostics
}

132
internal/lsp/language.go Normal file
View file

@ -0,0 +1,132 @@
package lsp
import (
"path/filepath"
"strings"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
func DetectLanguageID(uri string) protocol.LanguageKind {
ext := strings.ToLower(filepath.Ext(uri))
switch ext {
case ".abap":
return protocol.LangABAP
case ".bat":
return protocol.LangWindowsBat
case ".bib", ".bibtex":
return protocol.LangBibTeX
case ".clj":
return protocol.LangClojure
case ".coffee":
return protocol.LangCoffeescript
case ".c":
return protocol.LangC
case ".cpp", ".cxx", ".cc", ".c++":
return protocol.LangCPP
case ".cs":
return protocol.LangCSharp
case ".css":
return protocol.LangCSS
case ".d":
return protocol.LangD
case ".pas", ".pascal":
return protocol.LangDelphi
case ".diff", ".patch":
return protocol.LangDiff
case ".dart":
return protocol.LangDart
case ".dockerfile":
return protocol.LangDockerfile
case ".ex", ".exs":
return protocol.LangElixir
case ".erl", ".hrl":
return protocol.LangErlang
case ".fs", ".fsi", ".fsx", ".fsscript":
return protocol.LangFSharp
case ".gitcommit":
return protocol.LangGitCommit
case ".gitrebase":
return protocol.LangGitRebase
case ".go":
return protocol.LangGo
case ".groovy":
return protocol.LangGroovy
case ".hbs", ".handlebars":
return protocol.LangHandlebars
case ".hs":
return protocol.LangHaskell
case ".html", ".htm":
return protocol.LangHTML
case ".ini":
return protocol.LangIni
case ".java":
return protocol.LangJava
case ".js":
return protocol.LangJavaScript
case ".jsx":
return protocol.LangJavaScriptReact
case ".json":
return protocol.LangJSON
case ".tex", ".latex":
return protocol.LangLaTeX
case ".less":
return protocol.LangLess
case ".lua":
return protocol.LangLua
case ".makefile", "makefile":
return protocol.LangMakefile
case ".md", ".markdown":
return protocol.LangMarkdown
case ".m":
return protocol.LangObjectiveC
case ".mm":
return protocol.LangObjectiveCPP
case ".pl":
return protocol.LangPerl
case ".pm":
return protocol.LangPerl6
case ".php":
return protocol.LangPHP
case ".ps1", ".psm1":
return protocol.LangPowershell
case ".pug", ".jade":
return protocol.LangPug
case ".py":
return protocol.LangPython
case ".r":
return protocol.LangR
case ".cshtml", ".razor":
return protocol.LangRazor
case ".rb":
return protocol.LangRuby
case ".rs":
return protocol.LangRust
case ".scss":
return protocol.LangSCSS
case ".sass":
return protocol.LangSASS
case ".scala":
return protocol.LangScala
case ".shader":
return protocol.LangShaderLab
case ".sh", ".bash", ".zsh", ".ksh":
return protocol.LangShellScript
case ".sql":
return protocol.LangSQL
case ".swift":
return protocol.LangSwift
case ".ts":
return protocol.LangTypeScript
case ".tsx":
return protocol.LangTypeScriptReact
case ".xml":
return protocol.LangXML
case ".xsl":
return protocol.LangXSL
case ".yaml", ".yml":
return protocol.LangYAML
default:
return protocol.LanguageKind("") // Unknown language
}
}

554
internal/lsp/methods.go Normal file
View file

@ -0,0 +1,554 @@
// Generated code. Do not edit
package lsp
import (
"context"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
// Implementation sends a textDocument/implementation request to the LSP server.
// A request to resolve the implementation locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Definition or a Thenable that resolves to such.
func (c *Client) Implementation(ctx context.Context, params protocol.ImplementationParams) (protocol.Or_Result_textDocument_implementation, error) {
var result protocol.Or_Result_textDocument_implementation
err := c.Call(ctx, "textDocument/implementation", params, &result)
return result, err
}
// TypeDefinition sends a textDocument/typeDefinition request to the LSP server.
// A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Definition or a Thenable that resolves to such.
func (c *Client) TypeDefinition(ctx context.Context, params protocol.TypeDefinitionParams) (protocol.Or_Result_textDocument_typeDefinition, error) {
var result protocol.Or_Result_textDocument_typeDefinition
err := c.Call(ctx, "textDocument/typeDefinition", params, &result)
return result, err
}
// DocumentColor sends a textDocument/documentColor request to the LSP server.
// A request to list all color symbols found in a given text document. The request's parameter is of type DocumentColorParams the response is of type ColorInformation ColorInformation[] or a Thenable that resolves to such.
func (c *Client) DocumentColor(ctx context.Context, params protocol.DocumentColorParams) ([]protocol.ColorInformation, error) {
var result []protocol.ColorInformation
err := c.Call(ctx, "textDocument/documentColor", params, &result)
return result, err
}
// ColorPresentation sends a textDocument/colorPresentation request to the LSP server.
// A request to list all presentation for a color. The request's parameter is of type ColorPresentationParams the response is of type ColorInformation ColorInformation[] or a Thenable that resolves to such.
func (c *Client) ColorPresentation(ctx context.Context, params protocol.ColorPresentationParams) ([]protocol.ColorPresentation, error) {
var result []protocol.ColorPresentation
err := c.Call(ctx, "textDocument/colorPresentation", params, &result)
return result, err
}
// FoldingRange sends a textDocument/foldingRange request to the LSP server.
// A request to provide folding ranges in a document. The request's parameter is of type FoldingRangeParams, the response is of type FoldingRangeList or a Thenable that resolves to such.
func (c *Client) FoldingRange(ctx context.Context, params protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) {
var result []protocol.FoldingRange
err := c.Call(ctx, "textDocument/foldingRange", params, &result)
return result, err
}
// Declaration sends a textDocument/declaration request to the LSP server.
// A request to resolve the type definition locations of a symbol at a given text document position. The request's parameter is of type TextDocumentPositionParams the response is of type Declaration or a typed array of DeclarationLink or a Thenable that resolves to such.
func (c *Client) Declaration(ctx context.Context, params protocol.DeclarationParams) (protocol.Or_Result_textDocument_declaration, error) {
var result protocol.Or_Result_textDocument_declaration
err := c.Call(ctx, "textDocument/declaration", params, &result)
return result, err
}
// SelectionRange sends a textDocument/selectionRange request to the LSP server.
// A request to provide selection ranges in a document. The request's parameter is of type SelectionRangeParams, the response is of type SelectionRange SelectionRange[] or a Thenable that resolves to such.
func (c *Client) SelectionRange(ctx context.Context, params protocol.SelectionRangeParams) ([]protocol.SelectionRange, error) {
var result []protocol.SelectionRange
err := c.Call(ctx, "textDocument/selectionRange", params, &result)
return result, err
}
// PrepareCallHierarchy sends a textDocument/prepareCallHierarchy request to the LSP server.
// A request to result a CallHierarchyItem in a document at a given position. Can be used as an input to an incoming or outgoing call hierarchy. Since 3.16.0
func (c *Client) PrepareCallHierarchy(ctx context.Context, params protocol.CallHierarchyPrepareParams) ([]protocol.CallHierarchyItem, error) {
var result []protocol.CallHierarchyItem
err := c.Call(ctx, "textDocument/prepareCallHierarchy", params, &result)
return result, err
}
// IncomingCalls sends a callHierarchy/incomingCalls request to the LSP server.
// A request to resolve the incoming calls for a given CallHierarchyItem. Since 3.16.0
func (c *Client) IncomingCalls(ctx context.Context, params protocol.CallHierarchyIncomingCallsParams) ([]protocol.CallHierarchyIncomingCall, error) {
var result []protocol.CallHierarchyIncomingCall
err := c.Call(ctx, "callHierarchy/incomingCalls", params, &result)
return result, err
}
// OutgoingCalls sends a callHierarchy/outgoingCalls request to the LSP server.
// A request to resolve the outgoing calls for a given CallHierarchyItem. Since 3.16.0
func (c *Client) OutgoingCalls(ctx context.Context, params protocol.CallHierarchyOutgoingCallsParams) ([]protocol.CallHierarchyOutgoingCall, error) {
var result []protocol.CallHierarchyOutgoingCall
err := c.Call(ctx, "callHierarchy/outgoingCalls", params, &result)
return result, err
}
// SemanticTokensFull sends a textDocument/semanticTokens/full request to the LSP server.
// Since 3.16.0
func (c *Client) SemanticTokensFull(ctx context.Context, params protocol.SemanticTokensParams) (protocol.SemanticTokens, error) {
var result protocol.SemanticTokens
err := c.Call(ctx, "textDocument/semanticTokens/full", params, &result)
return result, err
}
// SemanticTokensFullDelta sends a textDocument/semanticTokens/full/delta request to the LSP server.
// Since 3.16.0
func (c *Client) SemanticTokensFullDelta(ctx context.Context, params protocol.SemanticTokensDeltaParams) (protocol.Or_Result_textDocument_semanticTokens_full_delta, error) {
var result protocol.Or_Result_textDocument_semanticTokens_full_delta
err := c.Call(ctx, "textDocument/semanticTokens/full/delta", params, &result)
return result, err
}
// SemanticTokensRange sends a textDocument/semanticTokens/range request to the LSP server.
// Since 3.16.0
func (c *Client) SemanticTokensRange(ctx context.Context, params protocol.SemanticTokensRangeParams) (protocol.SemanticTokens, error) {
var result protocol.SemanticTokens
err := c.Call(ctx, "textDocument/semanticTokens/range", params, &result)
return result, err
}
// LinkedEditingRange sends a textDocument/linkedEditingRange request to the LSP server.
// A request to provide ranges that can be edited together. Since 3.16.0
func (c *Client) LinkedEditingRange(ctx context.Context, params protocol.LinkedEditingRangeParams) (protocol.LinkedEditingRanges, error) {
var result protocol.LinkedEditingRanges
err := c.Call(ctx, "textDocument/linkedEditingRange", params, &result)
return result, err
}
// WillCreateFiles sends a workspace/willCreateFiles request to the LSP server.
// The will create files request is sent from the client to the server before files are actually created as long as the creation is triggered from within the client. The request can return a WorkspaceEdit which will be applied to workspace before the files are created. Hence the WorkspaceEdit can not manipulate the content of the file to be created. Since 3.16.0
func (c *Client) WillCreateFiles(ctx context.Context, params protocol.CreateFilesParams) (protocol.WorkspaceEdit, error) {
var result protocol.WorkspaceEdit
err := c.Call(ctx, "workspace/willCreateFiles", params, &result)
return result, err
}
// WillRenameFiles sends a workspace/willRenameFiles request to the LSP server.
// The will rename files request is sent from the client to the server before files are actually renamed as long as the rename is triggered from within the client. Since 3.16.0
func (c *Client) WillRenameFiles(ctx context.Context, params protocol.RenameFilesParams) (protocol.WorkspaceEdit, error) {
var result protocol.WorkspaceEdit
err := c.Call(ctx, "workspace/willRenameFiles", params, &result)
return result, err
}
// WillDeleteFiles sends a workspace/willDeleteFiles request to the LSP server.
// The did delete files notification is sent from the client to the server when files were deleted from within the client. Since 3.16.0
func (c *Client) WillDeleteFiles(ctx context.Context, params protocol.DeleteFilesParams) (protocol.WorkspaceEdit, error) {
var result protocol.WorkspaceEdit
err := c.Call(ctx, "workspace/willDeleteFiles", params, &result)
return result, err
}
// Moniker sends a textDocument/moniker request to the LSP server.
// A request to get the moniker of a symbol at a given text document position. The request parameter is of type TextDocumentPositionParams. The response is of type Moniker Moniker[] or null.
func (c *Client) Moniker(ctx context.Context, params protocol.MonikerParams) ([]protocol.Moniker, error) {
var result []protocol.Moniker
err := c.Call(ctx, "textDocument/moniker", params, &result)
return result, err
}
// PrepareTypeHierarchy sends a textDocument/prepareTypeHierarchy request to the LSP server.
// A request to result a TypeHierarchyItem in a document at a given position. Can be used as an input to a subtypes or supertypes type hierarchy. Since 3.17.0
func (c *Client) PrepareTypeHierarchy(ctx context.Context, params protocol.TypeHierarchyPrepareParams) ([]protocol.TypeHierarchyItem, error) {
var result []protocol.TypeHierarchyItem
err := c.Call(ctx, "textDocument/prepareTypeHierarchy", params, &result)
return result, err
}
// Supertypes sends a typeHierarchy/supertypes request to the LSP server.
// A request to resolve the supertypes for a given TypeHierarchyItem. Since 3.17.0
func (c *Client) Supertypes(ctx context.Context, params protocol.TypeHierarchySupertypesParams) ([]protocol.TypeHierarchyItem, error) {
var result []protocol.TypeHierarchyItem
err := c.Call(ctx, "typeHierarchy/supertypes", params, &result)
return result, err
}
// Subtypes sends a typeHierarchy/subtypes request to the LSP server.
// A request to resolve the subtypes for a given TypeHierarchyItem. Since 3.17.0
func (c *Client) Subtypes(ctx context.Context, params protocol.TypeHierarchySubtypesParams) ([]protocol.TypeHierarchyItem, error) {
var result []protocol.TypeHierarchyItem
err := c.Call(ctx, "typeHierarchy/subtypes", params, &result)
return result, err
}
// InlineValue sends a textDocument/inlineValue request to the LSP server.
// A request to provide inline values in a document. The request's parameter is of type InlineValueParams, the response is of type InlineValue InlineValue[] or a Thenable that resolves to such. Since 3.17.0
func (c *Client) InlineValue(ctx context.Context, params protocol.InlineValueParams) ([]protocol.InlineValue, error) {
var result []protocol.InlineValue
err := c.Call(ctx, "textDocument/inlineValue", params, &result)
return result, err
}
// InlayHint sends a textDocument/inlayHint request to the LSP server.
// A request to provide inlay hints in a document. The request's parameter is of type InlayHintsParams, the response is of type InlayHint InlayHint[] or a Thenable that resolves to such. Since 3.17.0
func (c *Client) InlayHint(ctx context.Context, params protocol.InlayHintParams) ([]protocol.InlayHint, error) {
var result []protocol.InlayHint
err := c.Call(ctx, "textDocument/inlayHint", params, &result)
return result, err
}
// Resolve sends a inlayHint/resolve request to the LSP server.
// A request to resolve additional properties for an inlay hint. The request's parameter is of type InlayHint, the response is of type InlayHint or a Thenable that resolves to such. Since 3.17.0
func (c *Client) Resolve(ctx context.Context, params protocol.InlayHint) (protocol.InlayHint, error) {
var result protocol.InlayHint
err := c.Call(ctx, "inlayHint/resolve", params, &result)
return result, err
}
// Diagnostic sends a textDocument/diagnostic request to the LSP server.
// The document diagnostic request definition. Since 3.17.0
func (c *Client) Diagnostic(ctx context.Context, params protocol.DocumentDiagnosticParams) (protocol.DocumentDiagnosticReport, error) {
var result protocol.DocumentDiagnosticReport
err := c.Call(ctx, "textDocument/diagnostic", params, &result)
return result, err
}
// DiagnosticWorkspace sends a workspace/diagnostic request to the LSP server.
// The workspace diagnostic request definition. Since 3.17.0
func (c *Client) DiagnosticWorkspace(ctx context.Context, params protocol.WorkspaceDiagnosticParams) (protocol.WorkspaceDiagnosticReport, error) {
var result protocol.WorkspaceDiagnosticReport
err := c.Call(ctx, "workspace/diagnostic", params, &result)
return result, err
}
// InlineCompletion sends a textDocument/inlineCompletion request to the LSP server.
// A request to provide inline completions in a document. The request's parameter is of type InlineCompletionParams, the response is of type InlineCompletion InlineCompletion[] or a Thenable that resolves to such. Since 3.18.0 PROPOSED
func (c *Client) InlineCompletion(ctx context.Context, params protocol.InlineCompletionParams) (protocol.Or_Result_textDocument_inlineCompletion, error) {
var result protocol.Or_Result_textDocument_inlineCompletion
err := c.Call(ctx, "textDocument/inlineCompletion", params, &result)
return result, err
}
// TextDocumentContent sends a workspace/textDocumentContent request to the LSP server.
// The workspace/textDocumentContent request is sent from the client to the server to request the content of a text document. Since 3.18.0 PROPOSED
func (c *Client) TextDocumentContent(ctx context.Context, params protocol.TextDocumentContentParams) (string, error) {
var result string
err := c.Call(ctx, "workspace/textDocumentContent", params, &result)
return result, err
}
// Initialize sends a initialize request to the LSP server.
// The initialize request is sent from the client to the server. It is sent once as the request after starting up the server. The requests parameter is of type InitializeParams the response if of type InitializeResult of a Thenable that resolves to such.
func (c *Client) Initialize(ctx context.Context, params protocol.ParamInitialize) (protocol.InitializeResult, error) {
var result protocol.InitializeResult
err := c.Call(ctx, "initialize", params, &result)
return result, err
}
// Shutdown sends a shutdown request to the LSP server.
// A shutdown request is sent from the client to the server. It is sent once when the client decides to shutdown the server. The only notification that is sent after a shutdown request is the exit event.
func (c *Client) Shutdown(ctx context.Context) error {
return c.Call(ctx, "shutdown", nil, nil)
}
// WillSaveWaitUntil sends a textDocument/willSaveWaitUntil request to the LSP server.
// A document will save request is sent from the client to the server before the document is actually saved. The request can return an array of TextEdits which will be applied to the text document before it is saved. Please note that clients might drop results if computing the text edits took too long or if a server constantly fails on this request. This is done to keep the save fast and reliable.
func (c *Client) WillSaveWaitUntil(ctx context.Context, params protocol.WillSaveTextDocumentParams) ([]protocol.TextEdit, error) {
var result []protocol.TextEdit
err := c.Call(ctx, "textDocument/willSaveWaitUntil", params, &result)
return result, err
}
// Completion sends a textDocument/completion request to the LSP server.
// Request to request completion at a given text document position. The request's parameter is of type TextDocumentPosition the response is of type CompletionItem CompletionItem[] or CompletionList or a Thenable that resolves to such. The request can delay the computation of the CompletionItem.detail detail and CompletionItem.documentation documentation properties to the completionItem/resolve request. However, properties that are needed for the initial sorting and filtering, like sortText, filterText, insertText, and textEdit, must not be changed during resolve.
func (c *Client) Completion(ctx context.Context, params protocol.CompletionParams) (protocol.Or_Result_textDocument_completion, error) {
var result protocol.Or_Result_textDocument_completion
err := c.Call(ctx, "textDocument/completion", params, &result)
return result, err
}
// ResolveCompletionItem sends a completionItem/resolve request to the LSP server.
// Request to resolve additional information for a given completion item.The request's parameter is of type CompletionItem the response is of type CompletionItem or a Thenable that resolves to such.
func (c *Client) ResolveCompletionItem(ctx context.Context, params protocol.CompletionItem) (protocol.CompletionItem, error) {
var result protocol.CompletionItem
err := c.Call(ctx, "completionItem/resolve", params, &result)
return result, err
}
// Hover sends a textDocument/hover request to the LSP server.
// Request to request hover information at a given text document position. The request's parameter is of type TextDocumentPosition the response is of type Hover or a Thenable that resolves to such.
func (c *Client) Hover(ctx context.Context, params protocol.HoverParams) (protocol.Hover, error) {
var result protocol.Hover
err := c.Call(ctx, "textDocument/hover", params, &result)
return result, err
}
// SignatureHelp sends a textDocument/signatureHelp request to the LSP server.
func (c *Client) SignatureHelp(ctx context.Context, params protocol.SignatureHelpParams) (protocol.SignatureHelp, error) {
var result protocol.SignatureHelp
err := c.Call(ctx, "textDocument/signatureHelp", params, &result)
return result, err
}
// Definition sends a textDocument/definition request to the LSP server.
// A request to resolve the definition location of a symbol at a given text document position. The request's parameter is of type TextDocumentPosition the response is of either type Definition or a typed array of DefinitionLink or a Thenable that resolves to such.
func (c *Client) Definition(ctx context.Context, params protocol.DefinitionParams) (protocol.Or_Result_textDocument_definition, error) {
var result protocol.Or_Result_textDocument_definition
err := c.Call(ctx, "textDocument/definition", params, &result)
return result, err
}
// References sends a textDocument/references request to the LSP server.
// A request to resolve project-wide references for the symbol denoted by the given text document position. The request's parameter is of type ReferenceParams the response is of type Location Location[] or a Thenable that resolves to such.
func (c *Client) References(ctx context.Context, params protocol.ReferenceParams) ([]protocol.Location, error) {
var result []protocol.Location
err := c.Call(ctx, "textDocument/references", params, &result)
return result, err
}
// DocumentHighlight sends a textDocument/documentHighlight request to the LSP server.
// Request to resolve a DocumentHighlight for a given text document position. The request's parameter is of type TextDocumentPosition the request response is an array of type DocumentHighlight or a Thenable that resolves to such.
func (c *Client) DocumentHighlight(ctx context.Context, params protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) {
var result []protocol.DocumentHighlight
err := c.Call(ctx, "textDocument/documentHighlight", params, &result)
return result, err
}
// DocumentSymbol sends a textDocument/documentSymbol request to the LSP server.
// A request to list all symbols found in a given text document. The request's parameter is of type TextDocumentIdentifier the response is of type SymbolInformation SymbolInformation[] or a Thenable that resolves to such.
func (c *Client) DocumentSymbol(ctx context.Context, params protocol.DocumentSymbolParams) (protocol.Or_Result_textDocument_documentSymbol, error) {
var result protocol.Or_Result_textDocument_documentSymbol
err := c.Call(ctx, "textDocument/documentSymbol", params, &result)
return result, err
}
// CodeAction sends a textDocument/codeAction request to the LSP server.
// A request to provide commands for the given text document and range.
func (c *Client) CodeAction(ctx context.Context, params protocol.CodeActionParams) ([]protocol.Or_Result_textDocument_codeAction_Item0_Elem, error) {
var result []protocol.Or_Result_textDocument_codeAction_Item0_Elem
err := c.Call(ctx, "textDocument/codeAction", params, &result)
return result, err
}
// ResolveCodeAction sends a codeAction/resolve request to the LSP server.
// Request to resolve additional information for a given code action.The request's parameter is of type CodeAction the response is of type CodeAction or a Thenable that resolves to such.
func (c *Client) ResolveCodeAction(ctx context.Context, params protocol.CodeAction) (protocol.CodeAction, error) {
var result protocol.CodeAction
err := c.Call(ctx, "codeAction/resolve", params, &result)
return result, err
}
// Symbol sends a workspace/symbol request to the LSP server.
// A request to list project-wide symbols matching the query string given by the WorkspaceSymbolParams. The response is of type SymbolInformation SymbolInformation[] or a Thenable that resolves to such. Since 3.17.0 - support for WorkspaceSymbol in the returned data. Clients need to advertise support for WorkspaceSymbols via the client capability workspace.symbol.resolveSupport.
func (c *Client) Symbol(ctx context.Context, params protocol.WorkspaceSymbolParams) (protocol.Or_Result_workspace_symbol, error) {
var result protocol.Or_Result_workspace_symbol
err := c.Call(ctx, "workspace/symbol", params, &result)
return result, err
}
// ResolveWorkspaceSymbol sends a workspaceSymbol/resolve request to the LSP server.
// A request to resolve the range inside the workspace symbol's location. Since 3.17.0
func (c *Client) ResolveWorkspaceSymbol(ctx context.Context, params protocol.WorkspaceSymbol) (protocol.WorkspaceSymbol, error) {
var result protocol.WorkspaceSymbol
err := c.Call(ctx, "workspaceSymbol/resolve", params, &result)
return result, err
}
// CodeLens sends a textDocument/codeLens request to the LSP server.
// A request to provide code lens for the given text document.
func (c *Client) CodeLens(ctx context.Context, params protocol.CodeLensParams) ([]protocol.CodeLens, error) {
var result []protocol.CodeLens
err := c.Call(ctx, "textDocument/codeLens", params, &result)
return result, err
}
// ResolveCodeLens sends a codeLens/resolve request to the LSP server.
// A request to resolve a command for a given code lens.
func (c *Client) ResolveCodeLens(ctx context.Context, params protocol.CodeLens) (protocol.CodeLens, error) {
var result protocol.CodeLens
err := c.Call(ctx, "codeLens/resolve", params, &result)
return result, err
}
// DocumentLink sends a textDocument/documentLink request to the LSP server.
// A request to provide document links
func (c *Client) DocumentLink(ctx context.Context, params protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) {
var result []protocol.DocumentLink
err := c.Call(ctx, "textDocument/documentLink", params, &result)
return result, err
}
// ResolveDocumentLink sends a documentLink/resolve request to the LSP server.
// Request to resolve additional information for a given document link. The request's parameter is of type DocumentLink the response is of type DocumentLink or a Thenable that resolves to such.
func (c *Client) ResolveDocumentLink(ctx context.Context, params protocol.DocumentLink) (protocol.DocumentLink, error) {
var result protocol.DocumentLink
err := c.Call(ctx, "documentLink/resolve", params, &result)
return result, err
}
// Formatting sends a textDocument/formatting request to the LSP server.
// A request to format a whole document.
func (c *Client) Formatting(ctx context.Context, params protocol.DocumentFormattingParams) ([]protocol.TextEdit, error) {
var result []protocol.TextEdit
err := c.Call(ctx, "textDocument/formatting", params, &result)
return result, err
}
// RangeFormatting sends a textDocument/rangeFormatting request to the LSP server.
// A request to format a range in a document.
func (c *Client) RangeFormatting(ctx context.Context, params protocol.DocumentRangeFormattingParams) ([]protocol.TextEdit, error) {
var result []protocol.TextEdit
err := c.Call(ctx, "textDocument/rangeFormatting", params, &result)
return result, err
}
// RangesFormatting sends a textDocument/rangesFormatting request to the LSP server.
// A request to format ranges in a document. Since 3.18.0 PROPOSED
func (c *Client) RangesFormatting(ctx context.Context, params protocol.DocumentRangesFormattingParams) ([]protocol.TextEdit, error) {
var result []protocol.TextEdit
err := c.Call(ctx, "textDocument/rangesFormatting", params, &result)
return result, err
}
// OnTypeFormatting sends a textDocument/onTypeFormatting request to the LSP server.
// A request to format a document on type.
func (c *Client) OnTypeFormatting(ctx context.Context, params protocol.DocumentOnTypeFormattingParams) ([]protocol.TextEdit, error) {
var result []protocol.TextEdit
err := c.Call(ctx, "textDocument/onTypeFormatting", params, &result)
return result, err
}
// Rename sends a textDocument/rename request to the LSP server.
// A request to rename a symbol.
func (c *Client) Rename(ctx context.Context, params protocol.RenameParams) (protocol.WorkspaceEdit, error) {
var result protocol.WorkspaceEdit
err := c.Call(ctx, "textDocument/rename", params, &result)
return result, err
}
// PrepareRename sends a textDocument/prepareRename request to the LSP server.
// A request to test and perform the setup necessary for a rename. Since 3.16 - support for default behavior
func (c *Client) PrepareRename(ctx context.Context, params protocol.PrepareRenameParams) (protocol.PrepareRenameResult, error) {
var result protocol.PrepareRenameResult
err := c.Call(ctx, "textDocument/prepareRename", params, &result)
return result, err
}
// ExecuteCommand sends a workspace/executeCommand request to the LSP server.
// A request send from the client to the server to execute a command. The request might return a workspace edit which the client will apply to the workspace.
func (c *Client) ExecuteCommand(ctx context.Context, params protocol.ExecuteCommandParams) (any, error) {
var result any
err := c.Call(ctx, "workspace/executeCommand", params, &result)
return result, err
}
// DidChangeWorkspaceFolders sends a workspace/didChangeWorkspaceFolders notification to the LSP server.
// The workspace/didChangeWorkspaceFolders notification is sent from the client to the server when the workspace folder configuration changes.
func (c *Client) DidChangeWorkspaceFolders(ctx context.Context, params protocol.DidChangeWorkspaceFoldersParams) error {
return c.Notify(ctx, "workspace/didChangeWorkspaceFolders", params)
}
// WorkDoneProgressCancel sends a window/workDoneProgress/cancel notification to the LSP server.
// The window/workDoneProgress/cancel notification is sent from the client to the server to cancel a progress initiated on the server side.
func (c *Client) WorkDoneProgressCancel(ctx context.Context, params protocol.WorkDoneProgressCancelParams) error {
return c.Notify(ctx, "window/workDoneProgress/cancel", params)
}
// DidCreateFiles sends a workspace/didCreateFiles notification to the LSP server.
// The did create files notification is sent from the client to the server when files were created from within the client. Since 3.16.0
func (c *Client) DidCreateFiles(ctx context.Context, params protocol.CreateFilesParams) error {
return c.Notify(ctx, "workspace/didCreateFiles", params)
}
// DidRenameFiles sends a workspace/didRenameFiles notification to the LSP server.
// The did rename files notification is sent from the client to the server when files were renamed from within the client. Since 3.16.0
func (c *Client) DidRenameFiles(ctx context.Context, params protocol.RenameFilesParams) error {
return c.Notify(ctx, "workspace/didRenameFiles", params)
}
// DidDeleteFiles sends a workspace/didDeleteFiles notification to the LSP server.
// The will delete files request is sent from the client to the server before files are actually deleted as long as the deletion is triggered from within the client. Since 3.16.0
func (c *Client) DidDeleteFiles(ctx context.Context, params protocol.DeleteFilesParams) error {
return c.Notify(ctx, "workspace/didDeleteFiles", params)
}
// DidOpenNotebookDocument sends a notebookDocument/didOpen notification to the LSP server.
// A notification sent when a notebook opens. Since 3.17.0
func (c *Client) DidOpenNotebookDocument(ctx context.Context, params protocol.DidOpenNotebookDocumentParams) error {
return c.Notify(ctx, "notebookDocument/didOpen", params)
}
// DidChangeNotebookDocument sends a notebookDocument/didChange notification to the LSP server.
func (c *Client) DidChangeNotebookDocument(ctx context.Context, params protocol.DidChangeNotebookDocumentParams) error {
return c.Notify(ctx, "notebookDocument/didChange", params)
}
// DidSaveNotebookDocument sends a notebookDocument/didSave notification to the LSP server.
// A notification sent when a notebook document is saved. Since 3.17.0
func (c *Client) DidSaveNotebookDocument(ctx context.Context, params protocol.DidSaveNotebookDocumentParams) error {
return c.Notify(ctx, "notebookDocument/didSave", params)
}
// DidCloseNotebookDocument sends a notebookDocument/didClose notification to the LSP server.
// A notification sent when a notebook closes. Since 3.17.0
func (c *Client) DidCloseNotebookDocument(ctx context.Context, params protocol.DidCloseNotebookDocumentParams) error {
return c.Notify(ctx, "notebookDocument/didClose", params)
}
// Initialized sends a initialized notification to the LSP server.
// The initialized notification is sent from the client to the server after the client is fully initialized and the server is allowed to send requests from the server to the client.
func (c *Client) Initialized(ctx context.Context, params protocol.InitializedParams) error {
return c.Notify(ctx, "initialized", params)
}
// Exit sends a exit notification to the LSP server.
// The exit event is sent from the client to the server to ask the server to exit its process.
func (c *Client) Exit(ctx context.Context) error {
return c.Notify(ctx, "exit", nil)
}
// DidChangeConfiguration sends a workspace/didChangeConfiguration notification to the LSP server.
// The configuration change notification is sent from the client to the server when the client's configuration has changed. The notification contains the changed configuration as defined by the language client.
func (c *Client) DidChangeConfiguration(ctx context.Context, params protocol.DidChangeConfigurationParams) error {
return c.Notify(ctx, "workspace/didChangeConfiguration", params)
}
// DidOpen sends a textDocument/didOpen notification to the LSP server.
// The document open notification is sent from the client to the server to signal newly opened text documents. The document's truth is now managed by the client and the server must not try to read the document's truth using the document's uri. Open in this sense means it is managed by the client. It doesn't necessarily mean that its content is presented in an editor. An open notification must not be sent more than once without a corresponding close notification send before. This means open and close notification must be balanced and the max open count is one.
func (c *Client) DidOpen(ctx context.Context, params protocol.DidOpenTextDocumentParams) error {
return c.Notify(ctx, "textDocument/didOpen", params)
}
// DidChange sends a textDocument/didChange notification to the LSP server.
// The document change notification is sent from the client to the server to signal changes to a text document.
func (c *Client) DidChange(ctx context.Context, params protocol.DidChangeTextDocumentParams) error {
return c.Notify(ctx, "textDocument/didChange", params)
}
// DidClose sends a textDocument/didClose notification to the LSP server.
// The document close notification is sent from the client to the server when the document got closed in the client. The document's truth now exists where the document's uri points to (e.g. if the document's uri is a file uri the truth now exists on disk). As with the open notification the close notification is about managing the document's content. Receiving a close notification doesn't mean that the document was open in an editor before. A close notification requires a previous open notification to be sent.
func (c *Client) DidClose(ctx context.Context, params protocol.DidCloseTextDocumentParams) error {
return c.Notify(ctx, "textDocument/didClose", params)
}
// DidSave sends a textDocument/didSave notification to the LSP server.
// The document save notification is sent from the client to the server when the document got saved in the client.
func (c *Client) DidSave(ctx context.Context, params protocol.DidSaveTextDocumentParams) error {
return c.Notify(ctx, "textDocument/didSave", params)
}
// WillSave sends a textDocument/willSave notification to the LSP server.
// A document will save notification is sent from the client to the server before the document is actually saved.
func (c *Client) WillSave(ctx context.Context, params protocol.WillSaveTextDocumentParams) error {
return c.Notify(ctx, "textDocument/willSave", params)
}
// DidChangeWatchedFiles sends a workspace/didChangeWatchedFiles notification to the LSP server.
// The watched files notification is sent from the client to the server when the client detects changes to file watched by the language client.
func (c *Client) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
return c.Notify(ctx, "workspace/didChangeWatchedFiles", params)
}
// SetTrace sends a $/setTrace notification to the LSP server.
func (c *Client) SetTrace(ctx context.Context, params protocol.SetTraceParams) error {
return c.Notify(ctx, "$/setTrace", params)
}
// Progress sends a $/progress notification to the LSP server.
func (c *Client) Progress(ctx context.Context, params protocol.ProgressParams) error {
return c.Notify(ctx, "$/progress", params)
}

48
internal/lsp/protocol.go Normal file
View file

@ -0,0 +1,48 @@
package lsp
import (
"encoding/json"
)
// Message represents a JSON-RPC 2.0 message
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID int32 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *ResponseError `json:"error,omitempty"`
}
// ResponseError represents a JSON-RPC 2.0 error
type ResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func NewRequest(id int32, method string, params any) (*Message, error) {
paramsJSON, err := json.Marshal(params)
if err != nil {
return nil, err
}
return &Message{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: paramsJSON,
}, nil
}
func NewNotification(method string, params any) (*Message, error) {
paramsJSON, err := json.Marshal(params)
if err != nil {
return nil, err
}
return &Message{
JSONRPC: "2.0",
Method: method,
Params: paramsJSON,
}, nil
}

View file

@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,117 @@
package protocol
import "fmt"
// TextEditResult is an interface for types that represent workspace symbols
type WorkspaceSymbolResult interface {
GetName() string
GetLocation() Location
isWorkspaceSymbol() // marker method
}
func (ws *WorkspaceSymbol) GetName() string { return ws.Name }
func (ws *WorkspaceSymbol) GetLocation() Location {
switch v := ws.Location.Value.(type) {
case Location:
return v
case LocationUriOnly:
return Location{URI: v.URI}
}
return Location{}
}
func (ws *WorkspaceSymbol) isWorkspaceSymbol() {}
func (si *SymbolInformation) GetName() string { return si.Name }
func (si *SymbolInformation) GetLocation() Location { return si.Location }
func (si *SymbolInformation) isWorkspaceSymbol() {}
// Results converts the Value to a slice of WorkspaceSymbolResult
func (r Or_Result_workspace_symbol) Results() ([]WorkspaceSymbolResult, error) {
if r.Value == nil {
return make([]WorkspaceSymbolResult, 0), nil
}
switch v := r.Value.(type) {
case []WorkspaceSymbol:
results := make([]WorkspaceSymbolResult, len(v))
for i := range v {
results[i] = &v[i]
}
return results, nil
case []SymbolInformation:
results := make([]WorkspaceSymbolResult, len(v))
for i := range v {
results[i] = &v[i]
}
return results, nil
default:
return nil, fmt.Errorf("unknown symbol type: %T", r.Value)
}
}
// TextEditResult is an interface for types that represent document symbols
type DocumentSymbolResult interface {
GetRange() Range
GetName() string
isDocumentSymbol() // marker method
}
func (ds *DocumentSymbol) GetRange() Range { return ds.Range }
func (ds *DocumentSymbol) GetName() string { return ds.Name }
func (ds *DocumentSymbol) isDocumentSymbol() {}
func (si *SymbolInformation) GetRange() Range { return si.Location.Range }
// Note: SymbolInformation already has GetName() implemented above
func (si *SymbolInformation) isDocumentSymbol() {}
// Results converts the Value to a slice of DocumentSymbolResult
func (r Or_Result_textDocument_documentSymbol) Results() ([]DocumentSymbolResult, error) {
if r.Value == nil {
return make([]DocumentSymbolResult, 0), nil
}
switch v := r.Value.(type) {
case []DocumentSymbol:
results := make([]DocumentSymbolResult, len(v))
for i := range v {
results[i] = &v[i]
}
return results, nil
case []SymbolInformation:
results := make([]DocumentSymbolResult, len(v))
for i := range v {
results[i] = &v[i]
}
return results, nil
default:
return nil, fmt.Errorf("unknown document symbol type: %T", v)
}
}
// TextEditResult is an interface for types that can be used as text edits
type TextEditResult interface {
GetRange() Range
GetNewText() string
isTextEdit() // marker method
}
func (te *TextEdit) GetRange() Range { return te.Range }
func (te *TextEdit) GetNewText() string { return te.NewText }
func (te *TextEdit) isTextEdit() {}
// Convert Or_TextDocumentEdit_edits_Elem to TextEdit
func (e Or_TextDocumentEdit_edits_Elem) AsTextEdit() (TextEdit, error) {
if e.Value == nil {
return TextEdit{}, fmt.Errorf("nil text edit")
}
switch v := e.Value.(type) {
case TextEdit:
return v, nil
case AnnotatedTextEdit:
return TextEdit{
Range: v.Range,
NewText: v.NewText,
}, nil
default:
return TextEdit{}, fmt.Errorf("unknown text edit type: %T", e.Value)
}
}

View file

@ -0,0 +1,58 @@
package protocol
import (
"fmt"
"strings"
)
// PatternInfo is an interface for types that represent glob patterns
type PatternInfo interface {
GetPattern() string
GetBasePath() string
isPattern() // marker method
}
// StringPattern implements PatternInfo for string patterns
type StringPattern struct {
Pattern string
}
func (p StringPattern) GetPattern() string { return p.Pattern }
func (p StringPattern) GetBasePath() string { return "" }
func (p StringPattern) isPattern() {}
// RelativePatternInfo implements PatternInfo for RelativePattern
type RelativePatternInfo struct {
RP RelativePattern
BasePath string
}
func (p RelativePatternInfo) GetPattern() string { return string(p.RP.Pattern) }
func (p RelativePatternInfo) GetBasePath() string { return p.BasePath }
func (p RelativePatternInfo) isPattern() {}
// AsPattern converts GlobPattern to a PatternInfo object
func (g *GlobPattern) AsPattern() (PatternInfo, error) {
if g.Value == nil {
return nil, fmt.Errorf("nil pattern")
}
switch v := g.Value.(type) {
case string:
return StringPattern{Pattern: v}, nil
case RelativePattern:
// Handle BaseURI which could be string or DocumentUri
basePath := ""
switch baseURI := v.BaseURI.Value.(type) {
case string:
basePath = strings.TrimPrefix(baseURI, "file://")
case DocumentUri:
basePath = strings.TrimPrefix(string(baseURI), "file://")
default:
return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value)
}
return RelativePatternInfo{RP: v, BasePath: basePath}, nil
default:
return nil, fmt.Errorf("unknown pattern type: %T", g.Value)
}
}

View file

@ -0,0 +1,30 @@
package protocol
var TableKindMap = map[SymbolKind]string{
File: "File",
Module: "Module",
Namespace: "Namespace",
Package: "Package",
Class: "Class",
Method: "Method",
Property: "Property",
Field: "Field",
Constructor: "Constructor",
Enum: "Enum",
Interface: "Interface",
Function: "Function",
Variable: "Variable",
Constant: "Constant",
String: "String",
Number: "Number",
Boolean: "Boolean",
Array: "Array",
Object: "Object",
Key: "Key",
Null: "Null",
EnumMember: "EnumMember",
Struct: "Struct",
Event: "Event",
Operator: "Operator",
TypeParameter: "TypeParameter",
}

View file

@ -0,0 +1,81 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package protocol
import (
"encoding/json"
"fmt"
)
// DocumentChange is a union of various file edit operations.
//
// Exactly one field of this struct is non-nil; see [DocumentChange.Valid].
//
// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#resourceChanges
type DocumentChange struct {
TextDocumentEdit *TextDocumentEdit
CreateFile *CreateFile
RenameFile *RenameFile
DeleteFile *DeleteFile
}
// Valid reports whether the DocumentChange sum-type value is valid,
// that is, exactly one of create, delete, edit, or rename.
func (ch DocumentChange) Valid() bool {
n := 0
if ch.TextDocumentEdit != nil {
n++
}
if ch.CreateFile != nil {
n++
}
if ch.RenameFile != nil {
n++
}
if ch.DeleteFile != nil {
n++
}
return n == 1
}
func (d *DocumentChange) UnmarshalJSON(data []byte) error {
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
return err
}
if _, ok := m["textDocument"]; ok {
d.TextDocumentEdit = new(TextDocumentEdit)
return json.Unmarshal(data, d.TextDocumentEdit)
}
// The {Create,Rename,Delete}File types all share a 'kind' field.
kind := m["kind"]
switch kind {
case "create":
d.CreateFile = new(CreateFile)
return json.Unmarshal(data, d.CreateFile)
case "rename":
d.RenameFile = new(RenameFile)
return json.Unmarshal(data, d.RenameFile)
case "delete":
d.DeleteFile = new(DeleteFile)
return json.Unmarshal(data, d.DeleteFile)
}
return fmt.Errorf("DocumentChanges: unexpected kind: %q", kind)
}
func (d *DocumentChange) MarshalJSON() ([]byte, error) {
if d.TextDocumentEdit != nil {
return json.Marshal(d.TextDocumentEdit)
} else if d.CreateFile != nil {
return json.Marshal(d.CreateFile)
} else if d.RenameFile != nil {
return json.Marshal(d.RenameFile)
} else if d.DeleteFile != nil {
return json.Marshal(d.DeleteFile)
}
return nil, fmt.Errorf("empty DocumentChanges union value")
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,218 @@
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package protocol
// This file declares URI, DocumentUri, and its methods.
//
// For the LSP definition of these types, see
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#uri
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"unicode"
)
// A DocumentUri is the URI of a client editor document.
//
// According to the LSP specification:
//
// Care should be taken to handle encoding in URIs. For
// example, some clients (such as VS Code) may encode colons
// in drive letters while others do not. The URIs below are
// both valid, but clients and servers should be consistent
// with the form they use themselves to ensure the other party
// doesnt interpret them as distinct URIs. Clients and
// servers should not assume that each other are encoding the
// same way (for example a client encoding colons in drive
// letters cannot assume server responses will have encoded
// colons). The same applies to casing of drive letters - one
// party should not assume the other party will return paths
// with drive letters cased the same as it.
//
// file:///c:/project/readme.md
// file:///C%3A/project/readme.md
//
// This is done during JSON unmarshalling;
// see [DocumentUri.UnmarshalText] for details.
type DocumentUri string
// A URI is an arbitrary URL (e.g. https), not necessarily a file.
type URI = string
// UnmarshalText implements decoding of DocumentUri values.
//
// In particular, it implements a systematic correction of various odd
// features of the definition of DocumentUri in the LSP spec that
// appear to be workarounds for bugs in VS Code. For example, it may
// URI-encode the URI itself, so that colon becomes %3A, and it may
// send file://foo.go URIs that have two slashes (not three) and no
// hostname.
//
// We use UnmarshalText, not UnmarshalJSON, because it is called even
// for non-addressable values such as keys and values of map[K]V,
// where there is no pointer of type *K or *V on which to call
// UnmarshalJSON. (See Go issue #28189 for more detail.)
//
// Non-empty DocumentUris are valid "file"-scheme URIs.
// The empty DocumentUri is valid.
func (uri *DocumentUri) UnmarshalText(data []byte) (err error) {
*uri, err = ParseDocumentUri(string(data))
return
}
// Path returns the file path for the given URI.
//
// DocumentUri("").Path() returns the empty string.
//
// Path panics if called on a URI that is not a valid filename.
func (uri DocumentUri) Path() string {
filename, err := filename(uri)
if err != nil {
// e.g. ParseRequestURI failed.
//
// This can only affect DocumentUris created by
// direct string manipulation; all DocumentUris
// received from the client pass through
// ParseRequestURI, which ensures validity.
panic(err)
}
return filepath.FromSlash(filename)
}
// Dir returns the URI for the directory containing the receiver.
func (uri DocumentUri) Dir() DocumentUri {
// This function could be more efficiently implemented by avoiding any call
// to Path(), but at least consolidates URI manipulation.
return URIFromPath(uri.DirPath())
}
// DirPath returns the file path to the directory containing this URI, which
// must be a file URI.
func (uri DocumentUri) DirPath() string {
return filepath.Dir(uri.Path())
}
func filename(uri DocumentUri) (string, error) {
if uri == "" {
return "", nil
}
// This conservative check for the common case
// of a simple non-empty absolute POSIX filename
// avoids the allocation of a net.URL.
if strings.HasPrefix(string(uri), "file:///") {
rest := string(uri)[len("file://"):] // leave one slash
for i := range len(rest) {
b := rest[i]
// Reject these cases:
if b < ' ' || b == 0x7f || // control character
b == '%' || b == '+' || // URI escape
b == ':' || // Windows drive letter
b == '@' || b == '&' || b == '?' { // authority or query
goto slow
}
}
return rest, nil
}
slow:
u, err := url.ParseRequestURI(string(uri))
if err != nil {
return "", err
}
if u.Scheme != fileScheme {
return "", fmt.Errorf("only file URIs are supported, got %q from %q", u.Scheme, uri)
}
// If the URI is a Windows URI, we trim the leading "/" and uppercase
// the drive letter, which will never be case sensitive.
if isWindowsDriveURIPath(u.Path) {
u.Path = strings.ToUpper(string(u.Path[1])) + u.Path[2:]
}
return u.Path, nil
}
// ParseDocumentUri interprets a string as a DocumentUri, applying VS
// Code workarounds; see [DocumentUri.UnmarshalText] for details.
func ParseDocumentUri(s string) (DocumentUri, error) {
if s == "" {
return "", nil
}
if !strings.HasPrefix(s, "file://") {
return "", fmt.Errorf("DocumentUri scheme is not 'file': %s", s)
}
// VS Code sends URLs with only two slashes,
// which are invalid. golang/go#39789.
if !strings.HasPrefix(s, "file:///") {
s = "file:///" + s[len("file://"):]
}
// Even though the input is a URI, it may not be in canonical form. VS Code
// in particular over-escapes :, @, etc. Unescape and re-encode to canonicalize.
path, err := url.PathUnescape(s[len("file://"):])
if err != nil {
return "", err
}
// File URIs from Windows may have lowercase drive letters.
// Since drive letters are guaranteed to be case insensitive,
// we change them to uppercase to remain consistent.
// For example, file:///c:/x/y/z becomes file:///C:/x/y/z.
if isWindowsDriveURIPath(path) {
path = path[:1] + strings.ToUpper(string(path[1])) + path[2:]
}
u := url.URL{Scheme: fileScheme, Path: path}
return DocumentUri(u.String()), nil
}
// URIFromPath returns DocumentUri for the supplied file path.
// Given "", it returns "".
func URIFromPath(path string) DocumentUri {
if path == "" {
return ""
}
if !isWindowsDrivePath(path) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
}
// Check the file path again, in case it became absolute.
if isWindowsDrivePath(path) {
path = "/" + strings.ToUpper(string(path[0])) + path[1:]
}
path = filepath.ToSlash(path)
u := url.URL{
Scheme: fileScheme,
Path: path,
}
return DocumentUri(u.String())
}
const fileScheme = "file"
// isWindowsDrivePath returns true if the file path is of the form used by
// Windows. We check if the path begins with a drive letter, followed by a ":".
// For example: C:/x/y/z.
func isWindowsDrivePath(path string) bool {
if len(path) < 3 {
return false
}
return unicode.IsLetter(rune(path[0])) && path[1] == ':'
}
// isWindowsDriveURIPath returns true if the file URI is of the format used by
// Windows URIs. The url.Parse package does not specially handle Windows paths
// (see golang/go#6027), so we check if the URI path has a drive prefix (e.g. "/C:").
func isWindowsDriveURIPath(uri string) bool {
if len(uri) < 4 {
return false
}
return uri[0] == '/' && unicode.IsLetter(rune(uri[1])) && uri[2] == ':'
}

269
internal/lsp/transport.go Normal file
View file

@ -0,0 +1,269 @@
package lsp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
)
var debug = os.Getenv("DEBUG") != ""
// Write writes an LSP message to the given writer
func WriteMessage(w io.Writer, msg *Message) error {
data, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
if debug {
log.Printf("%v", msg.Method)
log.Printf("-> Sending: %s", string(data))
}
_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
if err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
_, err = w.Write(data)
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
return nil
}
// ReadMessage reads a single LSP message from the given reader
func ReadMessage(r *bufio.Reader) (*Message, error) {
// Read headers
var contentLength int
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
line = strings.TrimSpace(line)
if debug {
log.Printf("<- Header: %s", line)
}
if line == "" {
break // End of headers
}
if strings.HasPrefix(line, "Content-Length: ") {
_, err := fmt.Sscanf(line, "Content-Length: %d", &contentLength)
if err != nil {
return nil, fmt.Errorf("invalid Content-Length: %w", err)
}
}
}
if debug {
log.Printf("<- Reading content with length: %d", contentLength)
}
// Read content
content := make([]byte, contentLength)
_, err := io.ReadFull(r, content)
if err != nil {
return nil, fmt.Errorf("failed to read content: %w", err)
}
if debug {
log.Printf("<- Received: %s", string(content))
}
// Parse message
var msg Message
if err := json.Unmarshal(content, &msg); err != nil {
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
}
return &msg, nil
}
// handleMessages reads and dispatches messages in a loop
func (c *Client) handleMessages() {
for {
msg, err := ReadMessage(c.stdout)
if err != nil {
if debug {
log.Printf("Error reading message: %v", err)
}
return
}
// Handle server->client request (has both Method and ID)
if msg.Method != "" && msg.ID != 0 {
if debug {
log.Printf("Received request from server: method=%s id=%d", msg.Method, msg.ID)
}
response := &Message{
JSONRPC: "2.0",
ID: msg.ID,
}
// Look up handler for this method
c.serverHandlersMu.RLock()
handler, ok := c.serverRequestHandlers[msg.Method]
c.serverHandlersMu.RUnlock()
if ok {
result, err := handler(msg.Params)
if err != nil {
response.Error = &ResponseError{
Code: -32603,
Message: err.Error(),
}
} else {
rawJSON, err := json.Marshal(result)
if err != nil {
response.Error = &ResponseError{
Code: -32603,
Message: fmt.Sprintf("failed to marshal response: %v", err),
}
} else {
response.Result = rawJSON
}
}
} else {
response.Error = &ResponseError{
Code: -32601,
Message: fmt.Sprintf("method not found: %s", msg.Method),
}
}
// Send response back to server
if err := WriteMessage(c.stdin, response); err != nil {
log.Printf("Error sending response to server: %v", err)
}
continue
}
// Handle notification (has Method but no ID)
if msg.Method != "" && msg.ID == 0 {
c.notificationMu.RLock()
handler, ok := c.notificationHandlers[msg.Method]
c.notificationMu.RUnlock()
if ok {
if debug {
log.Printf("Handling notification: %s", msg.Method)
}
go handler(msg.Params)
} else if debug {
log.Printf("No handler for notification: %s", msg.Method)
}
continue
}
// Handle response to our request (has ID but no Method)
if msg.ID != 0 && msg.Method == "" {
c.handlersMu.RLock()
ch, ok := c.handlers[msg.ID]
c.handlersMu.RUnlock()
if ok {
if debug {
log.Printf("Sending response for ID %d to handler", msg.ID)
}
ch <- msg
close(ch)
} else if debug {
log.Printf("No handler for response ID: %d", msg.ID)
}
}
}
}
// Call makes a request and waits for the response
func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
id := c.nextID.Add(1)
if debug {
log.Printf("Making call: method=%s id=%d", method, id)
}
msg, err := NewRequest(id, method, params)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Create response channel
ch := make(chan *Message, 1)
c.handlersMu.Lock()
c.handlers[id] = ch
c.handlersMu.Unlock()
defer func() {
c.handlersMu.Lock()
delete(c.handlers, id)
c.handlersMu.Unlock()
}()
// Send request
if err := WriteMessage(c.stdin, msg); err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
if debug {
log.Printf("Waiting for response to request ID: %d", id)
}
// Wait for response
resp := <-ch
if debug {
log.Printf("Received response for request ID: %d", id)
}
if resp.Error != nil {
return fmt.Errorf("request failed: %s (code: %d)", resp.Error.Message, resp.Error.Code)
}
if result != nil {
// If result is a json.RawMessage, just copy the raw bytes
if rawMsg, ok := result.(*json.RawMessage); ok {
*rawMsg = resp.Result
return nil
}
// Otherwise unmarshal into the provided type
if err := json.Unmarshal(resp.Result, result); err != nil {
return fmt.Errorf("failed to unmarshal result: %w", err)
}
}
return nil
}
// Notify sends a notification (a request without an ID that doesn't expect a response)
func (c *Client) Notify(ctx context.Context, method string, params any) error {
if debug {
log.Printf("Sending notification: method=%s", method)
}
msg, err := NewNotification(method, params)
if err != nil {
return fmt.Errorf("failed to create notification: %w", err)
}
if err := WriteMessage(c.stdin, msg); err != nil {
return fmt.Errorf("failed to send notification: %w", err)
}
return nil
}
type (
NotificationHandler func(params json.RawMessage)
ServerRequestHandler func(params json.RawMessage) (any, error)
)

239
internal/lsp/util/edit.go Normal file
View file

@ -0,0 +1,239 @@
package util
import (
"bytes"
"fmt"
"os"
"sort"
"strings"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
func applyTextEdits(uri protocol.DocumentUri, edits []protocol.TextEdit) error {
path := strings.TrimPrefix(string(uri), "file://")
// Read the file content
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Detect line ending style
var lineEnding string
if bytes.Contains(content, []byte("\r\n")) {
lineEnding = "\r\n"
} else {
lineEnding = "\n"
}
// Track if file ends with a newline
endsWithNewline := len(content) > 0 && bytes.HasSuffix(content, []byte(lineEnding))
// Split into lines without the endings
lines := strings.Split(string(content), lineEnding)
// Check for overlapping edits
for i := 0; i < len(edits); i++ {
for j := i + 1; j < len(edits); j++ {
if rangesOverlap(edits[i].Range, edits[j].Range) {
return fmt.Errorf("overlapping edits detected between edit %d and %d", i, j)
}
}
}
// Sort edits in reverse order
sortedEdits := make([]protocol.TextEdit, len(edits))
copy(sortedEdits, edits)
sort.Slice(sortedEdits, func(i, j int) bool {
if sortedEdits[i].Range.Start.Line != sortedEdits[j].Range.Start.Line {
return sortedEdits[i].Range.Start.Line > sortedEdits[j].Range.Start.Line
}
return sortedEdits[i].Range.Start.Character > sortedEdits[j].Range.Start.Character
})
// Apply each edit
for _, edit := range sortedEdits {
newLines, err := applyTextEdit(lines, edit, lineEnding)
if err != nil {
return fmt.Errorf("failed to apply edit: %w", err)
}
lines = newLines
}
// Join lines with proper line endings
var newContent strings.Builder
for i, line := range lines {
if i > 0 {
newContent.WriteString(lineEnding)
}
newContent.WriteString(line)
}
// Only add a newline if the original file had one and we haven't already added it
if endsWithNewline && !strings.HasSuffix(newContent.String(), lineEnding) {
newContent.WriteString(lineEnding)
}
if err := os.WriteFile(path, []byte(newContent.String()), 0o644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func applyTextEdit(lines []string, edit protocol.TextEdit, lineEnding string) ([]string, error) {
startLine := int(edit.Range.Start.Line)
endLine := int(edit.Range.End.Line)
startChar := int(edit.Range.Start.Character)
endChar := int(edit.Range.End.Character)
// Validate positions
if startLine < 0 || startLine >= len(lines) {
return nil, fmt.Errorf("invalid start line: %d", startLine)
}
if endLine < 0 || endLine >= len(lines) {
endLine = len(lines) - 1
}
// Create result slice with initial capacity
result := make([]string, 0, len(lines))
// Copy lines before edit
result = append(result, lines[:startLine]...)
// Get the prefix of the start line
startLineContent := lines[startLine]
if startChar < 0 || startChar > len(startLineContent) {
startChar = len(startLineContent)
}
prefix := startLineContent[:startChar]
// Get the suffix of the end line
endLineContent := lines[endLine]
if endChar < 0 || endChar > len(endLineContent) {
endChar = len(endLineContent)
}
suffix := endLineContent[endChar:]
// Handle the edit
if edit.NewText == "" {
if prefix+suffix != "" {
result = append(result, prefix+suffix)
}
} else {
// Split new text into lines, being careful not to add extra newlines
// newLines := strings.Split(strings.TrimRight(edit.NewText, "\n"), "\n")
newLines := strings.Split(edit.NewText, "\n")
if len(newLines) == 1 {
// Single line change
result = append(result, prefix+newLines[0]+suffix)
} else {
// Multi-line change
result = append(result, prefix+newLines[0])
result = append(result, newLines[1:len(newLines)-1]...)
result = append(result, newLines[len(newLines)-1]+suffix)
}
}
// Add remaining lines
if endLine+1 < len(lines) {
result = append(result, lines[endLine+1:]...)
}
return result, nil
}
// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
func applyDocumentChange(change protocol.DocumentChange) error {
if change.CreateFile != nil {
path := strings.TrimPrefix(string(change.CreateFile.URI), "file://")
if change.CreateFile.Options != nil {
if change.CreateFile.Options.Overwrite {
// Proceed with overwrite
} else if change.CreateFile.Options.IgnoreIfExists {
if _, err := os.Stat(path); err == nil {
return nil // File exists and we're ignoring it
}
}
}
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
}
if change.DeleteFile != nil {
path := strings.TrimPrefix(string(change.DeleteFile.URI), "file://")
if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to delete directory recursively: %w", err)
}
} else {
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
}
}
if change.RenameFile != nil {
oldPath := strings.TrimPrefix(string(change.RenameFile.OldURI), "file://")
newPath := strings.TrimPrefix(string(change.RenameFile.NewURI), "file://")
if change.RenameFile.Options != nil {
if !change.RenameFile.Options.Overwrite {
if _, err := os.Stat(newPath); err == nil {
return fmt.Errorf("target file already exists and overwrite is not allowed: %s", newPath)
}
}
}
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
}
if change.TextDocumentEdit != nil {
textEdits := make([]protocol.TextEdit, len(change.TextDocumentEdit.Edits))
for i, edit := range change.TextDocumentEdit.Edits {
var err error
textEdits[i], err = edit.AsTextEdit()
if err != nil {
return fmt.Errorf("invalid edit type: %w", err)
}
}
return applyTextEdits(change.TextDocumentEdit.TextDocument.URI, textEdits)
}
return nil
}
// ApplyWorkspaceEdit applies the given WorkspaceEdit to the filesystem
func ApplyWorkspaceEdit(edit protocol.WorkspaceEdit) error {
// Handle Changes field
for uri, textEdits := range edit.Changes {
if err := applyTextEdits(uri, textEdits); err != nil {
return fmt.Errorf("failed to apply text edits: %w", err)
}
}
// Handle DocumentChanges field
for _, change := range edit.DocumentChanges {
if err := applyDocumentChange(change); err != nil {
return fmt.Errorf("failed to apply document change: %w", err)
}
}
return nil
}
func rangesOverlap(r1, r2 protocol.Range) bool {
if r1.Start.Line > r2.End.Line || r2.Start.Line > r1.End.Line {
return false
}
if r1.Start.Line == r2.End.Line && r1.Start.Character > r2.End.Character {
return false
}
if r2.Start.Line == r1.End.Line && r2.Start.Character > r1.End.Character {
return false
}
return true
}

View file

@ -0,0 +1,633 @@
package watcher
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/kujtimiihoxha/termai/internal/lsp"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
)
var debug = false // Force debug logging on
// WorkspaceWatcher manages LSP file watching
type WorkspaceWatcher struct {
client *lsp.Client
workspacePath string
debounceTime time.Duration
debounceMap map[string]*time.Timer
debounceMu sync.Mutex
// File watchers registered by the server
registrations []protocol.FileSystemWatcher
registrationMu sync.RWMutex
}
// NewWorkspaceWatcher creates a new workspace watcher
func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
return &WorkspaceWatcher{
client: client,
debounceTime: 300 * time.Millisecond,
debounceMap: make(map[string]*time.Timer),
registrations: []protocol.FileSystemWatcher{},
}
}
// AddRegistrations adds file watchers to track
func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
w.registrationMu.Lock()
defer w.registrationMu.Unlock()
// Add new watchers
w.registrations = append(w.registrations, watchers...)
// Print detailed registration information for debugging
if debug {
log.Printf("Added %d file watcher registrations (id: %s), total: %d",
len(watchers), id, len(w.registrations))
for i, watcher := range watchers {
log.Printf("Registration #%d raw data:", i+1)
// Log the GlobPattern
switch v := watcher.GlobPattern.Value.(type) {
case string:
log.Printf(" GlobPattern: string pattern '%s'", v)
case protocol.RelativePattern:
log.Printf(" GlobPattern: RelativePattern with pattern '%s'", v.Pattern)
// Log BaseURI details
switch u := v.BaseURI.Value.(type) {
case string:
log.Printf(" BaseURI: string '%s'", u)
case protocol.DocumentUri:
log.Printf(" BaseURI: DocumentUri '%s'", u)
default:
log.Printf(" BaseURI: unknown type %T", u)
}
default:
log.Printf(" GlobPattern: unknown type %T", v)
}
// Log WatchKind
watchKind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
if watcher.Kind != nil {
watchKind = *watcher.Kind
}
log.Printf(" WatchKind: %d (Create:%v, Change:%v, Delete:%v)",
watchKind,
watchKind&protocol.WatchCreate != 0,
watchKind&protocol.WatchChange != 0,
watchKind&protocol.WatchDelete != 0)
// Test match against some example paths
testPaths := []string{
"/Users/phil/dev/mcp-language-server/internal/watcher/watcher.go",
"/Users/phil/dev/mcp-language-server/go.mod",
}
for _, testPath := range testPaths {
isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
log.Printf(" Test path '%s': %v", testPath, isMatch)
}
}
}
// Find and open all existing files that match the newly registered patterns
// TODO: not all language servers require this, but typescript does. Make this configurable
go func() {
startTime := time.Now()
filesOpened := 0
err := filepath.WalkDir(w.workspacePath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories that should be excluded
if d.IsDir() {
log.Println(path)
if path != w.workspacePath && shouldExcludeDir(path) {
if debug {
log.Printf("Skipping excluded directory!!: %s", path)
}
return filepath.SkipDir
}
} else {
// Process files
w.openMatchingFile(ctx, path)
filesOpened++
// Add a small delay after every 100 files to prevent overwhelming the server
if filesOpened%100 == 0 {
time.Sleep(10 * time.Millisecond)
}
}
return nil
})
elapsedTime := time.Since(startTime)
if debug {
log.Printf("Workspace scan complete: processed %d files in %.2f seconds", filesOpened, elapsedTime.Seconds())
}
if err != nil && debug {
log.Printf("Error scanning workspace for files to open: %v", err)
}
}()
}
// WatchWorkspace sets up file watching for a workspace
func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
w.workspacePath = workspacePath
// Register handler for file watcher registrations from the server
lsp.RegisterFileWatchHandler(func(id string, watchers []protocol.FileSystemWatcher) {
w.AddRegistrations(ctx, id, watchers)
})
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatalf("Error creating watcher: %v", err)
}
defer watcher.Close()
// Watch the workspace recursively
err = filepath.WalkDir(workspacePath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Skip excluded directories (except workspace root)
if d.IsDir() && path != workspacePath {
if shouldExcludeDir(path) {
if debug {
log.Printf("Skipping watching excluded directory: %s", path)
}
return filepath.SkipDir
}
}
// Add directories to watcher
if d.IsDir() {
err = watcher.Add(path)
if err != nil {
log.Printf("Error watching path %s: %v", path, err)
}
}
return nil
})
if err != nil {
log.Fatalf("Error walking workspace: %v", err)
}
// Event loop
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
return
}
uri := fmt.Sprintf("file://%s", event.Name)
// Add new directories to the watcher
if event.Op&fsnotify.Create != 0 {
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 {
log.Printf("Error watching new directory: %v", err)
}
}
} else {
// For newly created files
if !shouldExcludeFile(event.Name) {
w.openMatchingFile(ctx, event.Name)
}
}
}
}
// Debug logging
if debug {
matched, kind := w.isPathWatched(event.Name)
log.Printf("Event: %s, Op: %s, Watched: %v, Kind: %d",
event.Name, event.Op.String(), matched, kind)
}
// Check if this path should be watched according to server registrations
if watched, watchKind := w.isPathWatched(event.Name); watched {
switch {
case event.Op&fsnotify.Write != 0:
if watchKind&protocol.WatchChange != 0 {
w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Changed))
}
case event.Op&fsnotify.Create != 0:
// Already handled earlier in the event loop
// Just send the notification if needed
info, _ := os.Stat(event.Name)
if !info.IsDir() && watchKind&protocol.WatchCreate != 0 {
w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
}
case event.Op&fsnotify.Remove != 0:
if watchKind&protocol.WatchDelete != 0 {
w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
}
case event.Op&fsnotify.Rename != 0:
// For renames, first delete
if watchKind&protocol.WatchDelete != 0 {
w.handleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Deleted))
}
// Then check if the new file exists and create an event
if info, err := os.Stat(event.Name); err == nil && !info.IsDir() {
if watchKind&protocol.WatchCreate != 0 {
w.debounceHandleFileEvent(ctx, uri, protocol.FileChangeType(protocol.Created))
}
}
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("Watcher error: %v\n", err)
}
}
}
// isPathWatched checks if a path should be watched based on server registrations
func (w *WorkspaceWatcher) isPathWatched(path string) (bool, protocol.WatchKind) {
w.registrationMu.RLock()
defer w.registrationMu.RUnlock()
// If no explicit registrations, watch everything
if len(w.registrations) == 0 {
return true, protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
}
// Check each registration
for _, reg := range w.registrations {
isMatch := w.matchesPattern(path, reg.GlobPattern)
if isMatch {
kind := protocol.WatchKind(protocol.WatchChange | protocol.WatchCreate | protocol.WatchDelete)
if reg.Kind != nil {
kind = *reg.Kind
}
return true, kind
}
}
return false, 0
}
// matchesGlob handles advanced glob patterns including ** and alternatives
func matchesGlob(pattern, path string) bool {
// Handle file extension patterns with braces like *.{go,mod,sum}
if strings.Contains(pattern, "{") && strings.Contains(pattern, "}") {
// Extract extensions from pattern like "*.{go,mod,sum}"
parts := strings.SplitN(pattern, "{", 2)
if len(parts) == 2 {
prefix := parts[0]
extPart := strings.SplitN(parts[1], "}", 2)
if len(extPart) == 2 {
extensions := strings.Split(extPart[0], ",")
suffix := extPart[1]
// Check if the path matches any of the extensions
for _, ext := range extensions {
extPattern := prefix + ext + suffix
isMatch := matchesSimpleGlob(extPattern, path)
if isMatch {
return true
}
}
return false
}
}
}
return matchesSimpleGlob(pattern, path)
}
// matchesSimpleGlob handles glob patterns with ** wildcards
func matchesSimpleGlob(pattern, path string) bool {
// Handle special case for **/*.ext pattern (common in LSP)
if strings.HasPrefix(pattern, "**/") {
rest := strings.TrimPrefix(pattern, "**/")
// If the rest is a simple file extension pattern like *.go
if strings.HasPrefix(rest, "*.") {
ext := strings.TrimPrefix(rest, "*")
isMatch := strings.HasSuffix(path, ext)
return isMatch
}
// Otherwise, try to check if the path ends with the rest part
isMatch := strings.HasSuffix(path, rest)
// If it matches directly, great!
if isMatch {
return true
}
// Otherwise, check if any path component matches
pathComponents := strings.Split(path, "/")
for i := 0; i < len(pathComponents); i++ {
subPath := strings.Join(pathComponents[i:], "/")
if strings.HasSuffix(subPath, rest) {
return true
}
}
return false
}
// Handle other ** wildcard pattern cases
if strings.Contains(pattern, "**") {
parts := strings.Split(pattern, "**")
// Validate the path starts with the first part
if !strings.HasPrefix(path, parts[0]) && parts[0] != "" {
return false
}
// For patterns like "**/*.go", just check the suffix
if len(parts) == 2 && parts[0] == "" {
isMatch := strings.HasSuffix(path, parts[1])
return isMatch
}
// For other patterns, handle middle part
remaining := strings.TrimPrefix(path, parts[0])
if len(parts) == 2 {
isMatch := strings.HasSuffix(remaining, parts[1])
return isMatch
}
}
// Handle simple * wildcard for file extension patterns (*.go, *.sum, etc)
if strings.HasPrefix(pattern, "*.") {
ext := strings.TrimPrefix(pattern, "*")
isMatch := strings.HasSuffix(path, ext)
return isMatch
}
// Fall back to simple matching for simpler patterns
matched, err := filepath.Match(pattern, path)
if err != nil {
log.Printf("Error matching pattern %s: %v", pattern, err)
return false
}
return matched
}
// matchesPattern checks if a path matches the glob pattern
func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
patternInfo, err := pattern.AsPattern()
if err != nil {
log.Printf("Error parsing pattern: %v", err)
return false
}
basePath := patternInfo.GetBasePath()
patternText := patternInfo.GetPattern()
path = filepath.ToSlash(path)
// For simple patterns without base path
if basePath == "" {
// Check if the pattern matches the full path or just the file extension
fullPathMatch := matchesGlob(patternText, path)
baseNameMatch := matchesGlob(patternText, filepath.Base(path))
return fullPathMatch || baseNameMatch
}
// For relative patterns
basePath = strings.TrimPrefix(basePath, "file://")
basePath = filepath.ToSlash(basePath)
// Make path relative to basePath for matching
relPath, err := filepath.Rel(basePath, path)
if err != nil {
log.Printf("Error getting relative path for %s: %v", path, err)
return false
}
relPath = filepath.ToSlash(relPath)
isMatch := matchesGlob(patternText, relPath)
return isMatch
}
// debounceHandleFileEvent handles file events with debouncing to reduce notifications
func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
w.debounceMu.Lock()
defer w.debounceMu.Unlock()
// Create a unique key based on URI and change type
key := fmt.Sprintf("%s:%d", uri, changeType)
// Cancel existing timer if any
if timer, exists := w.debounceMap[key]; exists {
timer.Stop()
}
// Create new timer
w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
w.handleFileEvent(ctx, uri, changeType)
// Cleanup timer after execution
w.debounceMu.Lock()
delete(w.debounceMap, key)
w.debounceMu.Unlock()
})
}
// handleFileEvent sends file change notifications
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.Changed) && w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
log.Printf("Error notifying change: %v", err)
}
return
}
// Notify LSP server about the file event using didChangeWatchedFiles
if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
log.Printf("Error notifying LSP server about file event: %v", err)
}
}
// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
if debug {
log.Printf("Notifying file event: %s (type: %d)", uri, changeType)
}
params := protocol.DidChangeWatchedFilesParams{
Changes: []protocol.FileEvent{
{
URI: protocol.DocumentUri(uri),
Type: changeType,
},
},
}
return w.client.DidChangeWatchedFiles(ctx, params)
}
// Common patterns for directories and files to exclude
// TODO: make configurable
var (
excludedDirNames = map[string]bool{
".git": true,
"node_modules": true,
"dist": true,
"build": true,
"out": true,
"bin": true,
".idea": true,
".vscode": true,
".cache": true,
"coverage": true,
"target": true, // Rust build output
"vendor": true, // Go vendor directory
}
excludedFileExtensions = map[string]bool{
".swp": true,
".swo": true,
".tmp": true,
".temp": true,
".bak": true,
".log": true,
".o": true, // Object files
".so": true, // Shared libraries
".dylib": true, // macOS shared libraries
".dll": true, // Windows shared libraries
".a": true, // Static libraries
".exe": true, // Windows executables
".lock": true, // Lock files
}
// Large binary files that shouldn't be opened
largeBinaryExtensions = map[string]bool{
".png": true,
".jpg": true,
".jpeg": true,
".gif": true,
".bmp": true,
".ico": true,
".zip": true,
".tar": true,
".gz": true,
".rar": true,
".7z": true,
".pdf": true,
".mp3": true,
".mp4": true,
".mov": true,
".wav": true,
".wasm": true,
}
// Maximum file size to open (5MB)
maxFileSize int64 = 5 * 1024 * 1024
)
// shouldExcludeDir returns true if the directory should be excluded from watching/opening
func shouldExcludeDir(dirPath string) bool {
dirName := filepath.Base(dirPath)
// Skip dot directories
if strings.HasPrefix(dirName, ".") {
return true
}
// Skip common excluded directories
if excludedDirNames[dirName] {
return true
}
return false
}
// shouldExcludeFile returns true if the file should be excluded from opening
func shouldExcludeFile(filePath string) bool {
fileName := filepath.Base(filePath)
// Skip dot files
if strings.HasPrefix(fileName, ".") {
return true
}
// Check file extension
ext := strings.ToLower(filepath.Ext(filePath))
if excludedFileExtensions[ext] || largeBinaryExtensions[ext] {
return true
}
// Skip temporary files
if strings.HasSuffix(filePath, "~") {
return true
}
// Check file size
info, err := os.Stat(filePath)
if err != nil {
// If we can't stat the file, skip it
return true
}
// Skip large files
if info.Size() > maxFileSize {
if debug {
log.Printf("Skipping large file: %s (%.2f MB)", filePath, float64(info.Size())/(1024*1024))
}
return true
}
return false
}
// openMatchingFile opens a file if it matches any of the registered patterns
func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
// Skip directories
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return
}
// Skip excluded files
if shouldExcludeFile(path) {
return
}
// Check if this path should be watched according to server registrations
if watched, _ := w.isPathWatched(path); watched {
// Don't need to check if it's already open - the client.OpenFile handles that
if err := w.client.OpenFile(ctx, path); err != nil && debug {
log.Printf("Error opening file %s: %v", path, err)
}
}
}

244
internal/message/content.go Normal file
View file

@ -0,0 +1,244 @@
package message
import (
"encoding/base64"
)
type MessageRole string
const (
Assistant MessageRole = "assistant"
User MessageRole = "user"
System MessageRole = "system"
Tool MessageRole = "tool"
)
type ContentPart interface {
isPart()
}
type ReasoningContent struct {
Thinking string `json:"thinking"`
}
func (tc ReasoningContent) String() string {
return tc.Thinking
}
func (ReasoningContent) isPart() {}
type TextContent struct {
Text string `json:"text"`
}
func (tc TextContent) String() string {
return tc.Text
}
func (TextContent) isPart() {}
type ImageURLContent struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
func (iuc ImageURLContent) String() string {
return iuc.URL
}
func (ImageURLContent) isPart() {}
type BinaryContent struct {
MIMEType string
Data []byte
}
func (bc BinaryContent) String() string {
base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
return "data:" + bc.MIMEType + ";base64," + base64Encoded
}
func (BinaryContent) isPart() {}
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Input string `json:"input"`
Type string `json:"type"`
Finished bool `json:"finished"`
}
func (ToolCall) isPart() {}
type ToolResult struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Content string `json:"content"`
IsError bool `json:"is_error"`
}
func (ToolResult) isPart() {}
type Finish struct {
Reason string `json:"reason"`
}
func (Finish) isPart() {}
type Message struct {
ID string
Role MessageRole
SessionID string
Parts []ContentPart
CreatedAt int64
UpdatedAt int64
}
func (m *Message) Content() TextContent {
for _, part := range m.Parts {
if c, ok := part.(TextContent); ok {
return c
}
}
return TextContent{}
}
func (m *Message) ReasoningContent() ReasoningContent {
for _, part := range m.Parts {
if c, ok := part.(ReasoningContent); ok {
return c
}
}
return ReasoningContent{}
}
func (m *Message) ImageURLContent() []ImageURLContent {
imageURLContents := make([]ImageURLContent, 0)
for _, part := range m.Parts {
if c, ok := part.(ImageURLContent); ok {
imageURLContents = append(imageURLContents, c)
}
}
return imageURLContents
}
func (m *Message) BinaryContent() []BinaryContent {
binaryContents := make([]BinaryContent, 0)
for _, part := range m.Parts {
if c, ok := part.(BinaryContent); ok {
binaryContents = append(binaryContents, c)
}
}
return binaryContents
}
func (m *Message) ToolCalls() []ToolCall {
toolCalls := make([]ToolCall, 0)
for _, part := range m.Parts {
if c, ok := part.(ToolCall); ok {
toolCalls = append(toolCalls, c)
}
}
return toolCalls
}
func (m *Message) ToolResults() []ToolResult {
toolResults := make([]ToolResult, 0)
for _, part := range m.Parts {
if c, ok := part.(ToolResult); ok {
toolResults = append(toolResults, c)
}
}
return toolResults
}
func (m *Message) IsFinished() bool {
for _, part := range m.Parts {
if _, ok := part.(Finish); ok {
return true
}
}
return false
}
func (m *Message) FinishReason() string {
for _, part := range m.Parts {
if c, ok := part.(Finish); ok {
return c.Reason
}
}
return ""
}
func (m *Message) IsThinking() bool {
if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() {
return true
}
return false
}
func (m *Message) AppendContent(delta string) {
found := false
for i, part := range m.Parts {
if c, ok := part.(TextContent); ok {
m.Parts[i] = TextContent{Text: c.Text + delta}
found = true
}
}
if !found {
m.Parts = append(m.Parts, TextContent{Text: delta})
}
}
func (m *Message) AppendReasoningContent(delta string) {
found := false
for i, part := range m.Parts {
if c, ok := part.(ReasoningContent); ok {
m.Parts[i] = ReasoningContent{Thinking: c.Thinking + delta}
found = true
}
}
if !found {
m.Parts = append(m.Parts, ReasoningContent{Thinking: delta})
}
}
func (m *Message) AddToolCall(tc ToolCall) {
for i, part := range m.Parts {
if c, ok := part.(ToolCall); ok {
if c.ID == tc.ID {
m.Parts[i] = tc
return
}
}
}
m.Parts = append(m.Parts, tc)
}
func (m *Message) SetToolCalls(tc []ToolCall) {
for _, toolCall := range tc {
m.Parts = append(m.Parts, toolCall)
}
}
func (m *Message) AddToolResult(tr ToolResult) {
m.Parts = append(m.Parts, tr)
}
func (m *Message) SetToolResults(tr []ToolResult) {
for _, toolResult := range tr {
m.Parts = append(m.Parts, toolResult)
}
}
func (m *Message) AddFinish(reason string) {
m.Parts = append(m.Parts, Finish{Reason: reason})
}
func (m *Message) AddImageURL(url, detail string) {
m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
}
func (m *Message) AddBinary(mimeType string, data []byte) {
m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
}

View file

@ -2,59 +2,17 @@ package message
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/kujtimiihoxha/termai/internal/db"
"github.com/kujtimiihoxha/termai/internal/pubsub"
)
type MessageRole string
const (
Assistant MessageRole = "assistant"
User MessageRole = "user"
System MessageRole = "system"
Tool MessageRole = "tool"
)
type ToolResult struct {
ToolCallID string
Content string
IsError bool
// TODO: support for images
}
type ToolCall struct {
ID string
Name string
Input string
Type string
}
type Message struct {
ID string
SessionID string
// NEW
Role MessageRole
Content string
Thinking string
Finished bool
ToolResults []ToolResult
ToolCalls []ToolCall
CreatedAt int64
UpdatedAt int64
}
type CreateMessageParams struct {
Role MessageRole
Content string
ToolCalls []ToolCall
ToolResults []ToolResult
Role MessageRole
Parts []ContentPart
}
type Service interface {
@ -73,6 +31,14 @@ type service struct {
ctx context.Context
}
func NewService(ctx context.Context, q db.Querier) Service {
return &service{
Broker: pubsub.NewBroker[Message](),
q: q,
ctx: ctx,
}
}
func (s *service) Delete(id string) error {
message, err := s.Get(id)
if err != nil {
@ -87,22 +53,21 @@ func (s *service) Delete(id string) error {
}
func (s *service) Create(sessionID string, params CreateMessageParams) (Message, error) {
toolCallsStr, err := json.Marshal(params.ToolCalls)
if err != nil {
return Message{}, err
}
toolResultsStr, err := json.Marshal(params.ToolResults)
if params.Role != Assistant {
params.Parts = append(params.Parts, Finish{
Reason: "stop",
})
}
partsJSON, err := marshallParts(params.Parts)
if err != nil {
return Message{}, err
}
dbMessage, err := s.q.CreateMessage(s.ctx, db.CreateMessageParams{
ID: uuid.New().String(),
SessionID: sessionID,
Role: string(params.Role),
Finished: params.Role != Assistant,
Content: params.Content,
ToolCalls: sql.NullString{String: string(toolCallsStr), Valid: true},
ToolResults: sql.NullString{String: string(toolResultsStr), Valid: true},
ID: uuid.New().String(),
SessionID: sessionID,
Role: string(params.Role),
Parts: string(partsJSON),
})
if err != nil {
return Message{}, err
@ -132,21 +97,13 @@ func (s *service) DeleteSessionMessages(sessionID string) error {
}
func (s *service) Update(message Message) error {
toolCallsStr, err := json.Marshal(message.ToolCalls)
if err != nil {
return err
}
toolResultsStr, err := json.Marshal(message.ToolResults)
parts, err := marshallParts(message.Parts)
if err != nil {
return err
}
err = s.q.UpdateMessage(s.ctx, db.UpdateMessageParams{
ID: message.ID,
Content: message.Content,
Thinking: message.Thinking,
Finished: message.Finished,
ToolCalls: sql.NullString{String: string(toolCallsStr), Valid: true},
ToolResults: sql.NullString{String: string(toolResultsStr), Valid: true},
ID: message.ID,
Parts: string(parts),
})
if err != nil {
return err
@ -179,40 +136,136 @@ func (s *service) List(sessionID string) ([]Message, error) {
}
func (s *service) fromDBItem(item db.Message) (Message, error) {
toolCalls := make([]ToolCall, 0)
if item.ToolCalls.Valid {
err := json.Unmarshal([]byte(item.ToolCalls.String), &toolCalls)
if err != nil {
return Message{}, err
}
parts, err := unmarshallParts([]byte(item.Parts))
if err != nil {
return Message{}, err
}
toolResults := make([]ToolResult, 0)
if item.ToolResults.Valid {
err := json.Unmarshal([]byte(item.ToolResults.String), &toolResults)
if err != nil {
return Message{}, err
}
}
return Message{
ID: item.ID,
SessionID: item.SessionID,
Role: MessageRole(item.Role),
Content: item.Content,
Thinking: item.Thinking,
Finished: item.Finished,
ToolCalls: toolCalls,
ToolResults: toolResults,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
SessionID: item.SessionID,
Role: MessageRole(item.Role),
Parts: parts,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}, nil
}
func NewService(ctx context.Context, q db.Querier) Service {
return &service{
Broker: pubsub.NewBroker[Message](),
q: q,
ctx: ctx,
}
type partType string
const (
reasoningType partType = "reasoning"
textType partType = "text"
imageURLType partType = "image_url"
binaryType partType = "binary"
toolCallType partType = "tool_call"
toolResultType partType = "tool_result"
finishType partType = "finish"
)
type partWrapper struct {
Type partType `json:"type"`
Data ContentPart `json:"data"`
}
func marshallParts(parts []ContentPart) ([]byte, error) {
wrappedParts := make([]partWrapper, len(parts))
for i, part := range parts {
var typ partType
switch part.(type) {
case ReasoningContent:
typ = reasoningType
case TextContent:
typ = textType
case ImageURLContent:
typ = imageURLType
case BinaryContent:
typ = binaryType
case ToolCall:
typ = toolCallType
case ToolResult:
typ = toolResultType
case Finish:
typ = finishType
default:
return nil, fmt.Errorf("unknown part type: %T", part)
}
wrappedParts[i] = partWrapper{
Type: typ,
Data: part,
}
}
return json.Marshal(wrappedParts)
}
func unmarshallParts(data []byte) ([]ContentPart, error) {
temp := []json.RawMessage{}
if err := json.Unmarshal(data, &temp); err != nil {
return nil, err
}
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 {
return nil, err
}
switch wrapper.Type {
case reasoningType:
part := ReasoningContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
case textType:
part := TextContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
case imageURLType:
part := ImageURLContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
case binaryType:
part := BinaryContent{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
case toolCallType:
part := ToolCall{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
case toolResultType:
part := ToolResult{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
case finishType:
part := Finish{}
if err := json.Unmarshal(wrapper.Data, &part); err != nil {
return nil, err
}
parts = append(parts, part)
default:
return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
}
}
return parts, nil
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
@ -13,6 +14,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/kujtimiihoxha/termai/internal/app"
"github.com/kujtimiihoxha/termai/internal/llm/agent"
"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
"github.com/kujtimiihoxha/termai/internal/message"
"github.com/kujtimiihoxha/termai/internal/pubsub"
"github.com/kujtimiihoxha/termai/internal/session"
@ -39,6 +41,7 @@ type messagesCmp struct {
height int
focused bool
cachedView string
timeLoaded time.Time
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -51,7 +54,8 @@ func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
}
for _, v := range m.messages {
for _, c := range v.ToolCalls {
for _, c := range v.ToolCalls() {
// the message is being added to the session of a tool called
if c.ID == msg.Payload.SessionID {
m.renderView()
m.viewport.GotoBottom()
@ -130,12 +134,11 @@ func hasUnfinishedMessages(messages []message.Message) bool {
return false
}
for _, msg := range messages {
if !msg.Finished {
if !msg.IsFinished() {
return true
}
}
lastMessage := messages[len(messages)-1]
return lastMessage.Role != message.Assistant
return false
}
func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
@ -205,7 +208,7 @@ func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.
findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
for _, msg := range messages {
if msg.Role == message.Tool {
for _, result := range msg.ToolResults {
for _, result := range msg.ToolResults() {
if result.ToolCallID == toolCallID {
return &result
}
@ -257,7 +260,7 @@ func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.
taskSessionMessages, _ := m.app.Messages.List(toolCall.ID)
for _, msg := range taskSessionMessages {
if msg.Role == message.Assistant {
for _, toolCall := range msg.ToolCalls {
for _, toolCall := range msg.ToolCalls() {
toolHeader := lipgloss.NewStyle().
Bold(true).
Foreground(styles.Blue).
@ -304,11 +307,11 @@ func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.
}
for _, msg := range futureMessages {
if msg.Content != "" {
if msg.Content().String() != "" {
break
}
for _, toolCall := range msg.ToolCalls {
for _, toolCall := range msg.ToolCalls() {
toolOutput := renderTool(toolCall)
allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
@ -339,10 +342,10 @@ func (m *messagesCmp) renderView() {
prevMessageWasUser := false
for inx, msg := range m.messages {
content := msg.Content
content := msg.Content().String()
if content != "" || prevMessageWasUser {
if msg.Thinking != "" && content == "" {
content = msg.Thinking
if msg.ReasoningContent().String() != "" && content == "" {
content = msg.ReasoningContent().String()
} else if content == "" {
content = "..."
}
@ -367,14 +370,14 @@ func (m *messagesCmp) renderView() {
EmbeddedText: borderText(msg.Role, currentMessage),
},
)
if len(msg.ToolCalls) > 0 {
content = m.renderMessageWithToolCall(content, msg.ToolCalls, m.messages[inx+1:])
if len(msg.ToolCalls()) > 0 {
content = m.renderMessageWithToolCall(content, msg.ToolCalls(), m.messages[inx+1:])
}
stringMessages = append(stringMessages, content)
currentMessage++
displayedMsgCount++
}
if msg.Role == message.User && msg.Content != "" {
if msg.Role == message.User && msg.Content().String() != "" {
prevMessageWasUser = true
} else {
prevMessageWasUser = false
@ -398,6 +401,57 @@ func (m *messagesCmp) Blur() tea.Cmd {
return nil
}
func (m *messagesCmp) projectDiagnostics() string {
errorDiagnostics := []protocol.Diagnostic{}
warnDiagnostics := []protocol.Diagnostic{}
hintDiagnostics := []protocol.Diagnostic{}
infoDiagnostics := []protocol.Diagnostic{}
for _, client := range m.app.LSPClients {
for _, d := range client.GetDiagnostics() {
for _, diag := range d {
switch diag.Severity {
case protocol.SeverityError:
errorDiagnostics = append(errorDiagnostics, diag)
case protocol.SeverityWarning:
warnDiagnostics = append(warnDiagnostics, diag)
case protocol.SeverityHint:
hintDiagnostics = append(hintDiagnostics, diag)
case protocol.SeverityInformation:
infoDiagnostics = append(infoDiagnostics, diag)
}
}
}
}
if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
if time.Since(m.timeLoaded) < time.Second*10 {
return "Loading diagnostics..."
}
return "No diagnostics"
}
diagnostics := []string{}
if len(errorDiagnostics) > 0 {
errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
diagnostics = append(diagnostics, errStr)
}
if len(warnDiagnostics) > 0 {
warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
diagnostics = append(diagnostics, warnStr)
}
if len(hintDiagnostics) > 0 {
hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
diagnostics = append(diagnostics, hintStr)
}
if len(infoDiagnostics) > 0 {
infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
diagnostics = append(diagnostics, infoStr)
}
return strings.Join(diagnostics, " ")
}
func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
title := m.session.Title
titleWidth := m.width / 2
@ -409,7 +463,7 @@ func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
}
borderTest := map[layout.BorderPosition]string{
layout.TopLeftBorder: title,
layout.BottomRightBorder: formatTokensAndCost(m.session.CompletionTokens+m.session.PromptTokens, m.session.Cost),
layout.BottomRightBorder: m.projectDiagnostics(),
}
if hasUnfinishedMessages(m.messages) {
borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
@ -442,6 +496,7 @@ func (m *messagesCmp) SetSize(width int, height int) {
}
func (m *messagesCmp) Init() tea.Cmd {
m.timeLoaded = time.Now()
return nil
}

View file

@ -245,4 +245,3 @@ func NewSessionsCmp(app *app.App) SessionsCmp {
focused: false,
}
}

View file

@ -9,8 +9,11 @@ const (
UserIcon string = ""
CheckIcon string = "✓"
ErrorIcon string = "✗"
ErrorIcon string = ""
WarningIcon string = ""
InfoIcon string = ""
HintIcon string = ""
SpinnerIcon string = "..."
SleepIcon string = "󰒲"
BugIcon string = ""
SleepIcon string = "󰒲"
)

View file

@ -1,6 +1,3 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package main
import (
@ -11,7 +8,8 @@ import (
)
func main() {
// Create a log file and make that the log output
// Create a log file and make that the log output DEBUG
// TODO: remove this on release
logfile, err := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
panic(err)