// Summary: Hexai LSP entrypoint; parses flags and delegates to internal/hexailsp.
package main
import (
"flag"
"log"
"os"
"codeberg.org/snonux/hexai/internal"
"codeberg.org/snonux/hexai/internal/hexailsp"
)
func main() {
logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
log.Println(internal.Version)
return
}
if err := hexailsp.Run(*logPath, os.Stdin, os.Stdout, os.Stderr); err != nil {
log.Fatalf("server error: %v", err)
}
}
// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli.
package main
import (
"context"
"flag"
"fmt"
"os"
"codeberg.org/snonux/hexai/internal"
"codeberg.org/snonux/hexai/internal/hexaicli"
)
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Fprintln(os.Stdout, internal.Version)
return
}
if err := hexaicli.Run(context.Background(), flag.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil {
os.Exit(1)
}
}
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"time"
"codeberg.org/snonux/hexai/internal/hexaiaction"
"codeberg.org/snonux/hexai/internal/tmux"
"golang.org/x/term"
)
func main() {
infile := flag.String("infile", "", "Read input from this file instead of stdin")
outfile := flag.String("outfile", "", "Write output to this file instead of stdout")
// Tmux/UI flags
forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)")
noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available")
uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)")
tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h")
tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)")
flag.Parse()
// Child mode: run TUI and write atomically to -outfile
if *uiChild {
if err := runChild(*infile, *outfile); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// Parent mode: decide inline vs tmux
if shouldRunInTmux(*forceTmux, *noTmux) {
if err := runInTmuxParent(*tmuxTarget, *tmuxSplit, *tmuxPercent); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// Inline path: only if we have a TTY for UI; otherwise echo input
if isTTY(os.Stdout.Fd()) && isTTY(os.Stdin.Fd()) {
in, out, closeIn, closeOut, err := openIO(*infile, *outfile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer closeIn()
defer closeOut()
if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// Fallback: no TTY and tmux not available; echo input to output
if err := echoThrough(*infile, *outfile); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// openIO returns readers/writers for infile/outfile flags with deferred closers.
func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) {
in := io.Reader(os.Stdin)
out := io.Writer(os.Stdout)
closeIn := func() {}
closeOut := func() {}
if path := infile; path != "" {
f, err := os.Open(path)
if err != nil {
return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-action: cannot open infile: %w", err)
}
in = f
closeIn = func() { _ = f.Close() }
}
if path := outfile; path != "" {
f, err := os.Create(path)
if err != nil {
return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-action: cannot open outfile: %w", err)
}
out = f
closeOut = func() { _ = f.Close() }
}
return in, out, closeIn, closeOut, nil
}
// runChild runs the interactive flow and writes the final output atomically to outfile.
var hexaiactionRun = hexaiaction.Run
func runChild(infile, outfile string) error {
if outfile == "" {
// No atomic handoff needed; just run normally to stdout
in, out, closeIn, closeOut, err := openIO(infile, "")
if err != nil {
return err
}
defer closeIn()
defer closeOut()
return hexaiactionRun(context.Background(), in, out, os.Stderr)
}
tmp := outfile + ".tmp"
in, out, closeIn, closeOut, err := openIO(infile, tmp)
if err != nil {
return err
}
defer closeIn()
if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
// On error, try to echo input to tmp to avoid blocking
closeOut()
if copyErr := echoThrough(infile, tmp); copyErr != nil {
return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr)
}
} else {
closeOut()
}
return os.Rename(tmp, outfile)
}
var isTTYFn = isTTY
var tmuxAvailableFn = tmux.Available
var splitRunFn = tmux.SplitRun
var osExecutableFn = os.Executable
func shouldRunInTmux(forceTmux, noTmux bool) bool {
if noTmux {
return false
}
if forceTmux {
return true
}
// Auto: prefer tmux when stdio are not TTYs (Helix :pipe scenario)
if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() {
return true
}
return false
}
func isTTY(fd uintptr) bool { return term.IsTerminal(int(fd)) }
func runInTmuxParent(target, split string, percent int) error {
// Prepare temp files
dir, err := os.MkdirTemp("", "hexai-action-")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(dir) }()
inPath := filepath.Join(dir, "input.txt")
outPath := filepath.Join(dir, "reply.txt")
// Read stdin and persist to inPath
if err := persistStdin(inPath); err != nil {
return err
}
// Build child argv
exe, err := osExecutableFn()
if err != nil {
return err
}
argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
// Spawn tmux split
opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
if err := splitRunFn(opts, argv); err != nil {
return err
}
// Wait for outfile to appear
if err := waitForFile(outPath, 60*time.Second); err != nil {
return err
}
// Print to stdout
return catFileToStdout(outPath)
}
func persistStdin(path string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if _, err := io.Copy(f, os.Stdin); err != nil {
return err
}
return f.Sync()
}
func waitForFile(path string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(path); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("hexai-action: timeout waiting for reply file")
}
time.Sleep(200 * time.Millisecond)
}
}
func catFileToStdout(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
_, err = io.Copy(os.Stdout, f)
return err
}
func echoThrough(infile, outfile string) error {
// Read from infile or stdin and write to outfile or stdout
var in io.Reader = os.Stdin
var out io.Writer = os.Stdout
if infile != "" {
f, err := os.Open(infile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
in = f
}
if outfile != "" {
f, err := os.Create(outfile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
out = f
}
_, err := io.Copy(out, in)
return err
}
// Summary: Application configuration model and loader; reads ~/.config/hexai/config.toml and merges defaults.
package appconfig
import (
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/pelletier/go-toml/v2"
)
// App holds user-configurable settings read from ~/.config/hexai/config.toml.
type App struct {
MaxTokens int `json:"max_tokens" toml:"max_tokens"`
ContextMode string `json:"context_mode" toml:"context_mode"`
ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"`
MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"`
LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"`
// Single knob for LSP requests; if set, overrides hardcoded temps in LSP.
CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"`
// Minimum identifier characters required for manual (TriggerKind=1) invoke
// to proceed without structural triggers. 0 means always allow.
ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix" toml:"manual_invoke_min_prefix"`
// Completion debounce in milliseconds. When > 0, the server waits until
// there has been no text change for at least this duration before sending
// an LLM completion request.
CompletionDebounceMs int `json:"completion_debounce_ms" toml:"completion_debounce_ms"`
// Completion throttle in milliseconds. When > 0, caps the minimum spacing
// between LLM requests (both chat and code-completer paths).
CompletionThrottleMs int `json:"completion_throttle_ms" toml:"completion_throttle_ms"`
TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"`
Provider string `json:"provider" toml:"provider"`
// Inline prompt trigger characters (default: >text> and >>text>)
InlineOpen string `json:"inline_open" toml:"inline_open"`
InlineClose string `json:"inline_close" toml:"inline_close"`
// In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;])
ChatSuffix string `json:"chat_suffix" toml:"chat_suffix"`
ChatPrefixes []string `json:"chat_prefixes" toml:"chat_prefixes"`
// Provider-specific options
OpenAIBaseURL string `json:"openai_base_url" toml:"openai_base_url"`
OpenAIModel string `json:"openai_model" toml:"openai_model"`
// Default temperature for OpenAI requests (nil means use provider default)
OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"`
OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"`
OllamaModel string `json:"ollama_model" toml:"ollama_model"`
// Default temperature for Ollama requests (nil means use provider default)
OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"`
CopilotBaseURL string `json:"copilot_base_url" toml:"copilot_base_url"`
CopilotModel string `json:"copilot_model" toml:"copilot_model"`
// Default temperature for Copilot requests (nil means use provider default)
CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"`
// Prompt templates (configured only via file; no env overrides)
// Completion/chat/code action/CLI prompt strings. See config.toml.example for placeholders.
// Completion
PromptCompletionSystemGeneral string `json:"-" toml:"-"`
PromptCompletionSystemParams string `json:"-" toml:"-"`
PromptCompletionSystemInline string `json:"-" toml:"-"`
PromptCompletionUserGeneral string `json:"-" toml:"-"`
PromptCompletionUserParams string `json:"-" toml:"-"`
PromptCompletionExtraHeader string `json:"-" toml:"-"`
// Provider-native code-completer
PromptNativeCompletion string `json:"-" toml:"-"`
// In-editor chat
PromptChatSystem string `json:"-" toml:"-"`
// Code actions
PromptCodeActionRewriteSystem string `json:"-" toml:"-"`
PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"`
PromptCodeActionDocumentSystem string `json:"-" toml:"-"`
PromptCodeActionRewriteUser string `json:"-" toml:"-"`
PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"`
PromptCodeActionDocumentUser string `json:"-" toml:"-"`
PromptCodeActionGoTestSystem string `json:"-" toml:"-"`
PromptCodeActionGoTestUser string `json:"-" toml:"-"`
// CLI
PromptCLIDefaultSystem string `json:"-" toml:"-"`
PromptCLIExplainSystem string `json:"-" toml:"-"`
}
// Constructor: defaults for App (kept first among functions)
func newDefaultConfig() App {
// Coding-friendly default temperature across providers
// Users can override per provider in config.toml (including 0.0).
t := 0.2
return App{
MaxTokens: 4000,
ContextMode: "always-full",
ContextWindowLines: 120,
MaxContextTokens: 4000,
LogPreviewLimit: 100,
CodingTemperature: &t,
OpenAITemperature: &t,
OllamaTemperature: &t,
CopilotTemperature: &t,
ManualInvokeMinPrefix: 0,
CompletionDebounceMs: 200,
CompletionThrottleMs: 0,
// Inline/chat trigger defaults
InlineOpen: ">",
InlineClose: ">",
ChatSuffix: ">",
ChatPrefixes: []string{"?", "!", ":", ";"},
// Default prompt templates (match current hard-coded strings)
PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.",
PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}",
PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).",
PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.",
PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.",
PromptCompletionExtraHeader: "Additional context:\n{{context}}",
PromptNativeCompletion: "// Path: {{path}}\n{{before}}",
PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.",
PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.",
PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.",
PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.",
PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}",
PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}",
PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}",
PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.",
PromptCodeActionGoTestUser: "Function under test:\n{{function}}",
PromptCLIDefaultSystem: "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation.",
PromptCLIExplainSystem: "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context.",
}
}
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App {
cfg := newDefaultConfig()
if logger == nil {
return cfg // Return defaults if no logger is provided (e.g. in tests)
}
configPath, err := getConfigPath()
if err != nil {
logger.Printf("%v", err)
// Even if config path cannot be resolved, still allow env overrides below.
} else {
if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil {
cfg.mergeWith(fileCfg)
}
// When the config file is missing or invalid, we keep defaults and still
// apply any environment overrides below.
}
// Environment overrides (take precedence over file)
if envCfg := loadFromEnv(logger); envCfg != nil {
cfg.mergeWith(envCfg)
}
return cfg
}
// Private helpers
// Sectioned (table-based) file format only.
type fileConfig struct {
// Section tables only (flat keys are not allowed)
General sectionGeneral `toml:"general"`
Logging sectionLogging `toml:"logging"`
Completion sectionCompletion `toml:"completion"`
Triggers sectionTriggers `toml:"triggers"`
Inline sectionInline `toml:"inline"`
Chat sectionChat `toml:"chat"`
Provider sectionProvider `toml:"provider"`
OpenAI sectionOpenAI `toml:"openai"`
Copilot sectionCopilot `toml:"copilot"`
Ollama sectionOllama `toml:"ollama"`
Prompts sectionPrompts `toml:"prompts"`
}
type sectionGeneral struct {
MaxTokens int `toml:"max_tokens"`
ContextMode string `toml:"context_mode"`
ContextWindowLines int `toml:"context_window_lines"`
MaxContextTokens int `toml:"max_context_tokens"`
CodingTemperature *float64 `toml:"coding_temperature"`
}
type sectionLogging struct {
LogPreviewLimit int `toml:"log_preview_limit"`
}
type sectionCompletion struct {
CompletionDebounceMs int `toml:"completion_debounce_ms"`
CompletionThrottleMs int `toml:"completion_throttle_ms"`
ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"`
}
type sectionTriggers struct {
TriggerCharacters []string `toml:"trigger_characters"`
}
type sectionInline struct {
InlineOpen string `toml:"inline_open"`
InlineClose string `toml:"inline_close"`
}
type sectionChat struct {
ChatSuffix string `toml:"chat_suffix"`
ChatPrefixes []string `toml:"chat_prefixes"`
}
type sectionProvider struct {
Name string `toml:"name"`
}
type sectionOpenAI struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
Temperature *float64 `toml:"temperature"`
}
type sectionCopilot struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
Temperature *float64 `toml:"temperature"`
}
type sectionOllama struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
Temperature *float64 `toml:"temperature"`
}
// Prompts sections
type sectionPrompts struct {
Completion sectionPromptsCompletion `toml:"completion"`
Chat sectionPromptsChat `toml:"chat"`
CodeAction sectionPromptsCodeAction `toml:"code_action"`
CLI sectionPromptsCLI `toml:"cli"`
ProviderNative sectionPromptsProviderNative `toml:"provider_native"`
}
type sectionPromptsCompletion struct {
SystemGeneral string `toml:"system_general"`
SystemParams string `toml:"system_params"`
SystemInline string `toml:"system_inline"`
UserGeneral string `toml:"user_general"`
UserParams string `toml:"user_params"`
ExtraHeader string `toml:"additional_context"`
}
type sectionPromptsChat struct {
System string `toml:"system"`
}
type sectionPromptsCodeAction struct {
RewriteSystem string `toml:"rewrite_system"`
DiagnosticsSystem string `toml:"diagnostics_system"`
DocumentSystem string `toml:"document_system"`
RewriteUser string `toml:"rewrite_user"`
DiagnosticsUser string `toml:"diagnostics_user"`
DocumentUser string `toml:"document_user"`
GoTestSystem string `toml:"go_test_system"`
GoTestUser string `toml:"go_test_user"`
}
type sectionPromptsCLI struct {
DefaultSystem string `toml:"default_system"`
ExplainSystem string `toml:"explain_system"`
}
type sectionPromptsProviderNative struct {
Completion string `toml:"completion"`
}
func (fc *fileConfig) toApp() App {
out := App{}
// Merge section: general
if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil {
tmp := App{
MaxTokens: fc.General.MaxTokens,
ContextMode: fc.General.ContextMode,
ContextWindowLines: fc.General.ContextWindowLines,
MaxContextTokens: fc.General.MaxContextTokens,
CodingTemperature: fc.General.CodingTemperature,
}
out.mergeBasics(&tmp)
}
// logging
if (fc.Logging != sectionLogging{}) {
tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit}
out.mergeBasics(&tmp)
}
// completion
if (fc.Completion != sectionCompletion{}) {
tmp := App{
CompletionDebounceMs: fc.Completion.CompletionDebounceMs,
CompletionThrottleMs: fc.Completion.CompletionThrottleMs,
ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix,
}
out.mergeBasics(&tmp)
}
// triggers
if len(fc.Triggers.TriggerCharacters) > 0 {
tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters}
out.mergeBasics(&tmp)
}
// inline
if (fc.Inline != sectionInline{}) {
tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose}
out.mergeBasics(&tmp)
}
// chat
if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 {
tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes}
out.mergeBasics(&tmp)
}
// provider
if strings.TrimSpace(fc.Provider.Name) != "" {
tmp := App{Provider: fc.Provider.Name}
out.mergeBasics(&tmp)
}
// openai
if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil {
tmp := App{
OpenAIBaseURL: fc.OpenAI.BaseURL,
OpenAIModel: fc.OpenAI.Model,
OpenAITemperature: fc.OpenAI.Temperature,
}
out.mergeProviderFields(&tmp)
}
// copilot
if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil {
tmp := App{
CopilotBaseURL: fc.Copilot.BaseURL,
CopilotModel: fc.Copilot.Model,
CopilotTemperature: fc.Copilot.Temperature,
}
out.mergeProviderFields(&tmp)
}
// ollama
if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil {
tmp := App{
OllamaBaseURL: fc.Ollama.BaseURL,
OllamaModel: fc.Ollama.Model,
OllamaTemperature: fc.Ollama.Temperature,
}
out.mergeProviderFields(&tmp)
}
// prompts
// completion
if (fc.Prompts.Completion != sectionPromptsCompletion{}) {
if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" {
out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral
}
if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" {
out.PromptCompletionSystemParams = fc.Prompts.Completion.SystemParams
}
if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" {
out.PromptCompletionSystemInline = fc.Prompts.Completion.SystemInline
}
if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" {
out.PromptCompletionUserGeneral = fc.Prompts.Completion.UserGeneral
}
if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" {
out.PromptCompletionUserParams = fc.Prompts.Completion.UserParams
}
if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" {
out.PromptCompletionExtraHeader = fc.Prompts.Completion.ExtraHeader
}
}
// chat
if strings.TrimSpace(fc.Prompts.Chat.System) != "" {
out.PromptChatSystem = fc.Prompts.Chat.System
}
// code action
if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) {
if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" {
out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem
}
if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" {
out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem
}
if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" {
out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem
}
if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" {
out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser
}
if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" {
out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser
}
if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" {
out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser
}
if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" {
out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem
}
if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" {
out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser
}
}
// cli
if (fc.Prompts.CLI != sectionPromptsCLI{}) {
if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" {
out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem
}
if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" {
out.PromptCLIExplainSystem = fc.Prompts.CLI.ExplainSystem
}
}
// provider-native
if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" {
out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion
}
return out
}
func loadFromFile(path string, logger *log.Logger) (*App, error) {
b, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) && logger != nil {
logger.Printf("cannot open TOML config file %s: %v", path, err)
}
return nil, err
}
var tables fileConfig
errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables)
// Raw map for validation/presence checks
var raw map[string]any
_ = toml.Unmarshal(b, &raw)
if errTables != nil {
if logger != nil {
logger.Printf("invalid TOML config file %s: %v", path, errTables)
}
return nil, errTables
}
// Reject legacy flat keys at top-level (sectioned-only config is allowed)
legacy := map[string]struct{}{
"max_tokens": {}, "context_mode": {}, "context_window_lines": {}, "max_context_tokens": {},
"log_preview_limit": {}, "completion_debounce_ms": {}, "completion_throttle_ms": {},
"manual_invoke_min_prefix": {}, "trigger_characters": {}, "inline_open": {}, "inline_close": {},
"chat_suffix": {}, "chat_prefixes": {}, "coding_temperature": {}, "provider": {},
"openai_model": {}, "openai_base_url": {}, "openai_temperature": {},
"ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {},
"copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {},
}
for k := range raw {
if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable {
continue
}
if _, isLegacy := legacy[k]; isLegacy {
return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k)
}
}
if logger != nil {
logger.Printf("loaded configuration from %s (TOML)", path)
}
// Merge order: flat first, then tables (so tables win over zero flat values)
// Build App from tables only
tab := tables.toApp()
// Ensure explicit values from raw map are respected (defensive for ints)
if t, ok := raw["completion"].(map[string]any); ok {
if v, present := t["manual_invoke_min_prefix"]; present {
switch vv := v.(type) {
case int64:
tab.ManualInvokeMinPrefix = int(vv)
case int:
tab.ManualInvokeMinPrefix = vv
case float64:
tab.ManualInvokeMinPrefix = int(vv)
}
}
}
if t, ok := raw["logging"].(map[string]any); ok {
if v, present := t["log_preview_limit"]; present {
switch vv := v.(type) {
case int64:
tab.LogPreviewLimit = int(vv)
case int:
tab.LogPreviewLimit = vv
case float64:
tab.LogPreviewLimit = int(vv)
}
}
}
return &tab, nil
}
func (a *App) mergeWith(other *App) {
a.mergeBasics(other)
a.mergeProviderFields(other)
a.mergePrompts(other)
}
// mergeBasics merges general (non-provider) fields.
func (a *App) mergeBasics(other *App) {
if other.MaxTokens > 0 {
a.MaxTokens = other.MaxTokens
}
if s := strings.TrimSpace(other.ContextMode); s != "" {
a.ContextMode = s
}
if other.ContextWindowLines > 0 {
a.ContextWindowLines = other.ContextWindowLines
}
if other.MaxContextTokens > 0 {
a.MaxContextTokens = other.MaxContextTokens
}
if other.LogPreviewLimit >= 0 {
a.LogPreviewLimit = other.LogPreviewLimit
}
if other.CodingTemperature != nil { // allow explicit 0.0
a.CodingTemperature = other.CodingTemperature
}
if other.ManualInvokeMinPrefix >= 0 {
a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix
}
if other.CompletionDebounceMs > 0 {
a.CompletionDebounceMs = other.CompletionDebounceMs
}
if other.CompletionThrottleMs > 0 {
a.CompletionThrottleMs = other.CompletionThrottleMs
}
if len(other.TriggerCharacters) > 0 {
a.TriggerCharacters = slices.Clone(other.TriggerCharacters)
}
if s := strings.TrimSpace(other.InlineOpen); s != "" {
a.InlineOpen = s
}
if s := strings.TrimSpace(other.InlineClose); s != "" {
a.InlineClose = s
}
if s := strings.TrimSpace(other.ChatSuffix); s != "" {
a.ChatSuffix = s
}
if len(other.ChatPrefixes) > 0 {
a.ChatPrefixes = slices.Clone(other.ChatPrefixes)
}
if s := strings.TrimSpace(other.Provider); s != "" {
a.Provider = s
}
}
// mergePrompts copies non-empty prompt templates from other.
func (a *App) mergePrompts(other *App) {
// Completion
if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" {
a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral
}
if strings.TrimSpace(other.PromptCompletionSystemParams) != "" {
a.PromptCompletionSystemParams = other.PromptCompletionSystemParams
}
if strings.TrimSpace(other.PromptCompletionSystemInline) != "" {
a.PromptCompletionSystemInline = other.PromptCompletionSystemInline
}
if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" {
a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral
}
if strings.TrimSpace(other.PromptCompletionUserParams) != "" {
a.PromptCompletionUserParams = other.PromptCompletionUserParams
}
if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" {
a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader
}
// Provider-native
if strings.TrimSpace(other.PromptNativeCompletion) != "" {
a.PromptNativeCompletion = other.PromptNativeCompletion
}
// Chat
if strings.TrimSpace(other.PromptChatSystem) != "" {
a.PromptChatSystem = other.PromptChatSystem
}
// Code actions
if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" {
a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem
}
if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" {
a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem
}
if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" {
a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem
}
if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" {
a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser
}
if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" {
a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser
}
if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" {
a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser
}
if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" {
a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem
}
if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" {
a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser
}
// CLI
if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" {
a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem
}
if strings.TrimSpace(other.PromptCLIExplainSystem) != "" {
a.PromptCLIExplainSystem = other.PromptCLIExplainSystem
}
}
// mergeProviderFields merges per-provider configuration.
func (a *App) mergeProviderFields(other *App) {
if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" {
a.OpenAIBaseURL = s
}
if s := strings.TrimSpace(other.OpenAIModel); s != "" {
a.OpenAIModel = s
}
if other.OpenAITemperature != nil { // allow explicit 0.0
a.OpenAITemperature = other.OpenAITemperature
}
if s := strings.TrimSpace(other.OllamaBaseURL); s != "" {
a.OllamaBaseURL = s
}
if s := strings.TrimSpace(other.OllamaModel); s != "" {
a.OllamaModel = s
}
if other.OllamaTemperature != nil { // allow explicit 0.0
a.OllamaTemperature = other.OllamaTemperature
}
if s := strings.TrimSpace(other.CopilotBaseURL); s != "" {
a.CopilotBaseURL = s
}
if s := strings.TrimSpace(other.CopilotModel); s != "" {
a.CopilotModel = s
}
if other.CopilotTemperature != nil { // allow explicit 0.0
a.CopilotTemperature = other.CopilotTemperature
}
}
func getConfigPath() (string, error) {
var configPath string
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml")
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot find user home directory: %v", err)
}
configPath = filepath.Join(home, ".config", "hexai", "config.toml")
}
return configPath, nil
}
// --- Environment overrides ---
// loadFromEnv constructs an App containing only fields set via HEXAI_* env vars.
// These values should take precedence over file config when merged.
func loadFromEnv(logger *log.Logger) *App {
var out App
var any bool
// helpers
getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
parseInt := func(k string) (int, bool) {
v := getenv(k)
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
if logger != nil {
logger.Printf("invalid %s: %v", k, err)
}
return 0, false
}
return n, true
}
parseFloatPtr := func(k string) (*float64, bool) {
v := getenv(k)
if v == "" {
return nil, false
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
if logger != nil {
logger.Printf("invalid %s: %v", k, err)
}
return nil, false
}
return &f, true
}
if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok {
out.MaxTokens = n
any = true
}
if s := getenv("HEXAI_CONTEXT_MODE"); s != "" {
out.ContextMode = s
any = true
}
if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok {
out.ContextWindowLines = n
any = true
}
if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok {
out.MaxContextTokens = n
any = true
}
if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok {
out.LogPreviewLimit = n
any = true
}
if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok {
out.ManualInvokeMinPrefix = n
any = true
}
if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok {
out.CompletionDebounceMs = n
any = true
}
if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok {
out.CompletionThrottleMs = n
any = true
}
if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok {
out.CodingTemperature = f
any = true
}
if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" {
parts := strings.Split(s, ",")
out.TriggerCharacters = nil
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out.TriggerCharacters = append(out.TriggerCharacters, t)
}
}
any = true
}
if s := getenv("HEXAI_INLINE_OPEN"); s != "" {
out.InlineOpen = s
any = true
}
if s := getenv("HEXAI_INLINE_CLOSE"); s != "" {
out.InlineClose = s
any = true
}
if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" {
out.ChatSuffix = s
any = true
}
if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" {
parts := strings.Split(s, ",")
out.ChatPrefixes = nil
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out.ChatPrefixes = append(out.ChatPrefixes, t)
}
}
any = true
}
if s := getenv("HEXAI_PROVIDER"); s != "" {
out.Provider = s
any = true
}
// Provider-specific
if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" {
out.OpenAIBaseURL = s
any = true
}
if s := getenv("HEXAI_OPENAI_MODEL"); s != "" {
out.OpenAIModel = s
any = true
}
if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok {
out.OpenAITemperature = f
any = true
}
if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" {
out.OllamaBaseURL = s
any = true
}
if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" {
out.OllamaModel = s
any = true
}
if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok {
out.OllamaTemperature = f
any = true
}
if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" {
out.CopilotBaseURL = s
any = true
}
if s := getenv("HEXAI_COPILOT_MODEL"); s != "" {
out.CopilotModel = s
any = true
}
if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok {
out.CopilotTemperature = f
any = true
}
if !any {
return nil
}
return &out
}
package hexaiaction
import (
"bufio"
"io"
"strings"
"codeberg.org/snonux/hexai/internal/textutil"
)
// ParseInput splits raw stdin into optional diagnostics and selection/code.
// Format:
//
// Diagnostics:\n
// <one per line>\n
// <blank line> (optional)\n
// <rest is selection/code>
//
// If the header is absent, the entire input is treated as selection.
func ParseInput(r io.Reader) (InputParts, error) {
b, err := io.ReadAll(bufio.NewReader(r))
if err != nil {
return InputParts{}, err
}
raw := strings.TrimSpace(string(b))
if raw == "" {
return InputParts{Selection: ""}, nil
}
lines := strings.Split(raw, "\n")
// find a case-insensitive line equal to "diagnostics:"
diagsIdx := -1
for i, ln := range lines {
t := strings.TrimSpace(strings.ToLower(ln))
if t == "diagnostics:" {
diagsIdx = i
break
}
}
if diagsIdx < 0 {
return InputParts{Selection: raw}, nil
}
// collect diagnostics until a blank line or EOF
diags := []string{}
i := diagsIdx + 1
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if t == "" {
i++
break
}
diags = append(diags, t)
}
sel := strings.Join(lines[i:], "\n")
sel = strings.TrimSpace(sel)
return InputParts{Selection: sel, Diagnostics: diags}, nil
}
// ExtractInstruction mirrors the LSP instructionFromSelection behavior (subset),
// scanning the first line for an instruction marker and removing it from the selection.
func ExtractInstruction(sel string) (string, string) { return textutil.InstructionFromSelection(sel) }
// findFirstInstructionInLine follows the same precedence as LSP:
// - ;text; (strict)
// - /* text */ (single-line)
// - <!-- text --> (single-line)
// - // text
// - # text
// - -- text
// helpers moved to textutil
package hexaiaction
import (
"context"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/textutil"
)
// Render performs simple {{var}} replacement like LSP.
func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) }
// StripFences removes surrounding markdown code fences.
func StripFences(s string) string { return textutil.StripCodeFences(s) }
type chatDoer interface {
Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error)
}
func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) {
sys := cfg.PromptCodeActionRewriteSystem
user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection})
return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
}
func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) {
var b strings.Builder
for i, d := range diags {
if strings.TrimSpace(d) == "" {
continue
}
b.WriteString(strings.TrimSpace(d))
if i < len(diags)-1 {
b.WriteString("\n")
}
}
sys := cfg.PromptCodeActionDiagnosticsSystem
user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection})
return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
}
func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) {
sys := cfg.PromptCodeActionDocumentSystem
user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection})
return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
}
func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) {
sys := cfg.PromptCodeActionGoTestSystem
user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode})
return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
}
func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) {
msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
txt, err := client.Chat(ctx, msgs)
if err != nil {
return "", err
}
return strings.TrimSpace(StripFences(txt)), nil
}
func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) {
msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
txt, err := client.Chat(ctx, msgs, opts...)
if err != nil {
return "", err
}
return strings.TrimSpace(StripFences(txt)), nil
}
// reqOptsFrom builds LLM request options similar to LSP behavior.
func reqOptsFrom(cfg appconfig.App) []llm.RequestOption {
opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
if cfg.CodingTemperature != nil {
opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
}
return opts
}
// Timeout helpers to mirror LSP behavior.
func timeout10s(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, 10*time.Second)
}
func timeout8s(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, 8*time.Second)
}
package hexaiaction
import (
"context"
"fmt"
"io"
"log"
"strings"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/llmutils"
)
// Run executes the hexai-action command flow.
func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
logger := log.New(stderr, "hexai-action ", log.LstdFlags|log.Lmsgprefix)
cfg := appconfig.Load(logger)
client, err := llmutils.NewClientFromApp(cfg)
if err != nil {
fmt.Fprintf(stderr, logging.AnsiBase+"hexai-action: LLM disabled: %v"+logging.AnsiReset+"\n", err)
return err
}
parts, err := ParseInput(stdin)
if err != nil {
fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: failed to read input"+logging.AnsiReset)
return err
}
if strings.TrimSpace(parts.Selection) == "" {
return fmt.Errorf("hexai-action: no input provided on stdin")
}
kind, err := RunTUI()
if err != nil {
return err
}
out, err := executeAction(ctx, kind, parts, cfg, client, stderr)
if err != nil {
return err
}
io.WriteString(stdout, out)
return nil
}
func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) {
switch kind {
case ActionSkip:
return parts.Selection, nil
case ActionRewrite:
instr, cleaned := ExtractInstruction(parts.Selection)
if strings.TrimSpace(instr) == "" {
fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: no inline instruction found; echoing input"+logging.AnsiReset)
return parts.Selection, nil
}
cctx, cancel := timeout10s(ctx)
defer cancel()
return runRewrite(cctx, cfg, client, instr, cleaned)
case ActionDiagnostics:
cctx, cancel := timeout10s(ctx)
defer cancel()
return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection)
case ActionDocument:
cctx, cancel := timeout10s(ctx)
defer cancel()
return runDocument(cctx, cfg, client, parts.Selection)
case ActionGoTest:
cctx, cancel := timeout8s(ctx)
defer cancel()
return runGoTest(cctx, cfg, client, parts.Selection)
default:
return parts.Selection, nil
}
}
// client construction is shared via internal/llmutils
package hexaiaction
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
// item implements list.Item
type item struct {
title, desc string
kind ActionKind
hotkey rune
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
type model struct {
list list.Model
chosen ActionKind
done bool
}
func newModel() model {
items := []list.Item{
item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'},
item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'},
item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'},
item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'},
}
l := list.New(items, oneLineDelegate{}, 0, 0)
l.Title = "Select Hexai Action"
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
return model{list: l}
}
func (m model) Init() tea.Cmd { return nil }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return handleKey(m, msg)
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height)
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
raw := msg.String()
low := strings.ToLower(raw)
switch low {
case "esc", "q":
// Treat ESC and q as Skip/quit
m.chosen = ActionSkip
m.done = true
return m, tea.Quit
case "enter":
if it, ok := m.list.SelectedItem().(item); ok {
m.chosen = it.kind
m.done = true
return m, tea.Quit
}
case "j", "down":
m.list.CursorDown()
case "k", "up":
m.list.CursorUp()
case "g", "home":
m.list.Select(0)
case "end":
if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
case "s", "r", "c", "t":
items := m.list.Items()
for i := 0; i < len(items); i++ {
if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low {
m.list.Select(i)
m.chosen = it.kind
m.done = true
return m, tea.Quit
}
}
}
if raw == "G" { // Shift+G jumps to end
if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
}
return m, nil
}
func (m model) View() string {
if m.done {
return ""
}
return m.list.View()
}
// RunTUI returns the chosen ActionKind.
func RunTUI() (ActionKind, error) {
p := tea.NewProgram(newModel())
md, err := p.Run()
if err != nil {
return ActionSkip, err
}
if m, ok := md.(model); ok {
if m.chosen == "" {
return ActionSkip, nil
}
return m.chosen, nil
}
return ActionSkip, fmt.Errorf("unexpected model type")
}
package hexaiaction
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// oneLineDelegate renders a single compact line per item, no spacing.
type oneLineDelegate struct{}
var (
hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
cursorStyle = lipgloss.NewStyle().Bold(true)
)
func (oneLineDelegate) Height() int { return 1 }
func (oneLineDelegate) Spacing() int { return 0 }
func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
title := listItem.FilterValue()
hk := '?'
if it, ok := listItem.(item); ok {
hk = it.hotkey
}
hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk))
cursor := " "
if index == m.Index() {
cursor = cursorStyle.Render("> ")
}
fmt.Fprintf(w, "%s%s%s", cursor, title, hot)
}
// Summary: Hexai CLI runner; reads input, creates an LLM client, builds messages,
// streams or collects the model output, and prints a short summary to stderr.
package hexaicli
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/llmutils"
)
// Run executes the Hexai CLI behavior given arguments and I/O streams.
// It assumes flags have already been parsed by the caller.
func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
// Load configuration with a logger so file-based config is respected.
logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix)
cfg := appconfig.Load(logger)
client, err := llmutils.NewClientFromApp(cfg)
if err != nil {
fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err)
return err
}
// Inline the flow here to use configured CLI prompts.
input, rerr := readInput(stdin, args)
if rerr != nil {
fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset)
return rerr
}
printProviderInfo(stderr, client)
msgs := buildMessagesFromConfig(cfg, input)
if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil {
fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err)
return err
}
return nil
}
// RunWithClient executes the CLI flow using an already-constructed client.
// Useful for testing and embedding.
func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error {
input, err := readInput(stdin, args)
if err != nil {
fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset)
return err
}
printProviderInfo(stderr, client)
msgs := buildMessages(input)
if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil {
fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err)
return err
}
return nil
}
// readInput reads from stdin and args, then combines them per CLI rules.
func readInput(stdin io.Reader, args []string) (string, error) {
var stdinData string
if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 {
b, _ := io.ReadAll(bufio.NewReader(stdin))
stdinData = strings.TrimSpace(string(b))
}
argData := strings.TrimSpace(strings.Join(args, " "))
switch {
case stdinData != "" && argData != "":
return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil
case stdinData != "":
return stdinData, nil
case argData != "":
return argData, nil
default:
return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")
}
}
// newClientFromConfig builds an LLM client from the app config and env keys.
// client construction moved to internal/llmutils
// buildMessages creates system and user messages based on input content.
func buildMessages(input string) []llm.Message {
lower := strings.ToLower(input)
system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation."
if strings.Contains(lower, "explain") {
system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context."
}
return []llm.Message{
{Role: "system", Content: system},
{Role: "user", Content: input},
}
}
// buildMessagesFromConfig uses configured CLI system prompts.
func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message {
lower := strings.ToLower(input)
system := cfg.PromptCLIDefaultSystem
if strings.Contains(lower, "explain") {
if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" {
system = cfg.PromptCLIExplainSystem
}
}
return []llm.Message{
{Role: "system", Content: system},
{Role: "user", Content: input},
}
}
// runChat executes the chat request, handling streaming and summary output.
func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error {
start := time.Now()
var output string
if s, ok := client.(llm.Streamer); ok {
var b strings.Builder
if err := s.ChatStream(ctx, msgs, func(chunk string) {
b.WriteString(chunk)
fmt.Fprint(out, chunk)
}); err != nil {
return err
}
output = b.String()
} else {
txt, err := client.Chat(ctx, msgs)
if err != nil {
return err
}
output = txt
fmt.Fprint(out, output)
}
dur := time.Since(start)
fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n",
client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output))
return nil
}
// printProviderInfo writes the provider/model line to stderr.
func printProviderInfo(errw io.Writer, client llm.Client) {
fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel())
}
// newClientFromConfig is kept for tests; delegates to llmutils.
func newClientFromConfig(cfg appconfig.App) (llm.Client, error) {
return llmutils.NewClientFromApp(cfg)
}
// Summary: Hexai LSP runner; configures logging, loads config, builds the LLM client,
// and constructs/runs the LSP server (with injectable factory for tests).
package hexailsp
import (
"io"
"log"
"os"
"strings"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/lsp"
)
// ServerRunner is the minimal interface satisfied by lsp.Server.
type ServerRunner interface{ Run() error }
// ServerFactory creates a ServerRunner. Default uses lsp.NewServer.
type ServerFactory func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner
// Run configures logging, loads config, builds the LLM client and runs the LSP server.
// It is thin and delegates to RunWithFactory for testability.
func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix)
if strings.TrimSpace(logPath) != "" {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
logger.Fatalf("failed to open log file: %v", err)
}
defer f.Close()
logger.SetOutput(f)
}
logging.Bind(logger)
cfg := appconfig.Load(logger)
return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil)
}
// RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env.
// When factory is nil, lsp.NewServer is used.
func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error {
normalizeLoggingConfig(&cfg)
client = buildClientIfNil(cfg, client)
factory = ensureFactory(factory)
opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client)
server := factory(stdin, stdout, logger, opts)
if err := server.Run(); err != nil {
logger.Fatalf("server error: %v", err)
}
return nil
}
// --- helpers to keep RunWithFactory small ---
func normalizeLoggingConfig(cfg *appconfig.App) {
cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode))
if cfg.LogPreviewLimit >= 0 {
logging.SetLogPreviewLimit(cfg.LogPreviewLimit)
}
}
func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client {
if client != nil {
return client
}
llmCfg := llm.Config{
Provider: cfg.Provider,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
OpenAITemperature: cfg.OpenAITemperature,
OllamaBaseURL: cfg.OllamaBaseURL,
OllamaModel: cfg.OllamaModel,
OllamaTemperature: cfg.OllamaTemperature,
CopilotBaseURL: cfg.CopilotBaseURL,
CopilotModel: cfg.CopilotModel,
CopilotTemperature: cfg.CopilotTemperature,
}
// Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY
oaKey := os.Getenv("HEXAI_OPENAI_API_KEY")
if strings.TrimSpace(oaKey) == "" {
oaKey = os.Getenv("OPENAI_API_KEY")
}
// Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY
cpKey := os.Getenv("HEXAI_COPILOT_API_KEY")
if strings.TrimSpace(cpKey) == "" {
cpKey = os.Getenv("COPILOT_API_KEY")
}
if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil {
logging.Logf("lsp ", "llm disabled: %v", err)
return nil
} else {
logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel())
return c
}
}
func ensureFactory(factory ServerFactory) ServerFactory {
if factory != nil {
return factory
}
return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
return lsp.NewServer(r, w, logger, opts)
}
}
func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions {
return lsp.ServerOptions{
LogContext: logContext,
MaxTokens: cfg.MaxTokens,
ContextMode: cfg.ContextMode,
WindowLines: cfg.ContextWindowLines,
MaxContextTokens: cfg.MaxContextTokens,
CodingTemperature: cfg.CodingTemperature,
Client: client,
TriggerCharacters: cfg.TriggerCharacters,
ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix,
CompletionDebounceMs: cfg.CompletionDebounceMs,
CompletionThrottleMs: cfg.CompletionThrottleMs,
InlineOpen: cfg.InlineOpen,
InlineClose: cfg.InlineClose,
ChatSuffix: cfg.ChatSuffix,
ChatPrefixes: cfg.ChatPrefixes,
// Prompts
PromptCompSysGeneral: cfg.PromptCompletionSystemGeneral,
PromptCompSysParams: cfg.PromptCompletionSystemParams,
PromptCompSysInline: cfg.PromptCompletionSystemInline,
PromptCompUserGeneral: cfg.PromptCompletionUserGeneral,
PromptCompUserParams: cfg.PromptCompletionUserParams,
PromptCompExtraHeader: cfg.PromptCompletionExtraHeader,
PromptNativeCompletion: cfg.PromptNativeCompletion,
PromptChatSystem: cfg.PromptChatSystem,
PromptRewriteSystem: cfg.PromptCodeActionRewriteSystem,
PromptDiagnosticsSystem: cfg.PromptCodeActionDiagnosticsSystem,
PromptDocumentSystem: cfg.PromptCodeActionDocumentSystem,
PromptRewriteUser: cfg.PromptCodeActionRewriteUser,
PromptDiagnosticsUser: cfg.PromptCodeActionDiagnosticsUser,
PromptDocumentUser: cfg.PromptCodeActionDocumentUser,
PromptGoTestSystem: cfg.PromptCodeActionGoTestSystem,
PromptGoTestUser: cfg.PromptCodeActionGoTestUser,
}
}
// Summary: GitHub Copilot client for chat and Codex-style code completion.
package llm
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
appver "codeberg.org/snonux/hexai/internal"
"codeberg.org/snonux/hexai/internal/logging"
)
// copilotClient implements Client against GitHub Copilot's Chat Completions API.
type copilotClient struct {
httpClient *http.Client
apiKey string
baseURL string
defaultModel string
chatLogger logging.ChatLogger
defaultTemperature *float64
// cached Copilot session token retrieved from GitHub API using apiKey
sessionToken string
tokenExpiry time.Time
}
type copilotChatRequest struct {
Model string `json:"model"`
Messages []copilotMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Stop []string `json:"stop,omitempty"`
}
type copilotMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type copilotChatResponse struct {
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Param any `json:"param"`
Code any `json:"code"`
} `json:"error,omitempty"`
}
// Constructor (kept among the first functions by convention)
func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client {
if strings.TrimSpace(baseURL) == "" {
baseURL = "https://api.githubcopilot.com"
}
if strings.TrimSpace(model) == "" {
// GitHub Models (Copilot API) commonly supports gpt-4o/gpt-4o-mini.
// Default to a broadly available, cost-effective option.
model = "gpt-4o-mini"
}
return copilotClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: model,
chatLogger: logging.NewChatLogger("copilot"),
defaultTemperature: defaultTemp,
}
}
func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
if strings.TrimSpace(c.apiKey) == "" {
return nilStringErr("missing Copilot API key")
}
// Ensure we have a fresh session token
if err := c.ensureSession(ctx); err != nil {
return "", err
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
opt(&o)
}
if o.Model == "" {
o.Model = c.defaultModel
}
start := time.Now()
logMessages := make([]struct{ Role, Content string }, len(messages))
for i, m := range messages {
logMessages[i] = struct{ Role, Content string }{m.Role, m.Content}
}
c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
req := buildCopilotChatRequest(o, messages, c.defaultTemperature)
body, err := json.Marshal(req)
if err != nil {
logging.Logf("llm/copilot ", "marshal error: %v", err)
return "", err
}
endpoint := c.baseURL + "/chat/completions"
logging.Logf("llm/copilot ", "POST %s", endpoint)
resp, err := c.postJSON(ctx, endpoint, body, c.headersChat())
if err != nil {
logging.Logf("llm/copilot ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return "", err
}
defer resp.Body.Close()
if err := handleCopilotNon2xx(resp, start); err != nil {
return "", err
}
out, err := decodeCopilotChat(resp, start)
if err != nil {
return "", err
}
if len(out.Choices) == 0 {
logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
return "", errors.New("copilot: no choices returned")
}
content := out.Choices[0].Message.Content
logging.Logf("llm/copilot ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
return content, nil
}
// Provider metadata
func (c copilotClient) Name() string { return "copilot" }
func (c copilotClient) DefaultModel() string { return c.defaultModel }
// helpers
func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest {
req := copilotChatRequest{Model: o.Model}
req.Messages = make([]copilotMessage, len(messages))
for i, m := range messages {
req.Messages[i] = copilotMessage{Role: m.Role, Content: m.Content}
}
if o.Temperature != 0 {
req.Temperature = &o.Temperature
} else if defaultTemp != nil {
t := *defaultTemp
req.Temperature = &t
}
if o.MaxTokens > 0 {
req.MaxTokens = &o.MaxTokens
}
if len(o.Stop) > 0 {
req.Stop = o.Stop
}
return req
}
func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
return c.httpClient.Do(req)
}
func handleCopilotNon2xx(resp *http.Response, start time.Time) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
var apiErr copilotChatResponse
_ = json.NewDecoder(resp.Body).Decode(&apiErr)
if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" {
logging.Logf("llm/copilot ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
return fmt.Errorf("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
}
logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
return fmt.Errorf("copilot http error: status %d", resp.StatusCode)
}
func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) {
var out copilotChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return copilotChatResponse{}, err
}
return out, nil
}
// --- Copilot session token management ---
type ghCopilotTokenResp struct {
Token string `json:"token"`
}
func (c *copilotClient) ensureSession(ctx context.Context) error {
// If token valid for >60s, reuse
if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) {
return nil
}
if strings.TrimSpace(c.apiKey) == "" {
return errors.New("missing Copilot API key")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hexai/"+appver.Version)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("copilot token http error: %d", resp.StatusCode)
}
var out ghCopilotTokenResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
if strings.TrimSpace(out.Token) == "" {
return errors.New("empty copilot session token")
}
// Parse JWT exp
exp := parseJWTExp(out.Token)
if exp.IsZero() {
exp = time.Now().Add(10 * time.Minute)
}
c.sessionToken = out.Token
c.tokenExpiry = exp
return nil
}
var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode
func parseJWTExp(token string) time.Time {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return time.Time{}
}
b, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 {
if n, err2 := parseInt64(m[1]); err2 == nil {
return time.Unix(n, 0)
}
}
return time.Time{}
}
var payload struct {
Exp int64 `json:"exp"`
}
_ = json.Unmarshal(b, &payload)
if payload.Exp == 0 {
return time.Time{}
}
return time.Unix(payload.Exp, 0)
}
func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err }
// --- Copilot headers ---
func (c *copilotClient) headersChat() map[string]string {
_ = c.ensureSession(context.Background())
h := map[string]string{
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"Authorization": "Bearer " + c.sessionToken,
"User-Agent": "GitHubCopilotChat/0.8.0",
"Editor-Plugin-Version": "copilot-chat/0.8.0",
"Editor-Version": "vscode/1.85.1",
"Openai-Intent": "conversation-panel",
"Openai-Organization": "github-copilot",
"VScode-MachineId": randHex(64),
"VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12),
"X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12),
}
return h
}
func (c *copilotClient) headersGhost() map[string]string {
_ = c.ensureSession(context.Background())
h := map[string]string{
"Content-Type": "application/json; charset=utf-8",
"Accept": "*/*",
"Authorization": "Bearer " + c.sessionToken,
"User-Agent": "GithubCopilot/1.155.0",
"Editor-Plugin-Version": "copilot/1.155.0",
"Editor-Version": "vscode/1.85.1",
"Openai-Intent": "copilot-ghost",
"Openai-Organization": "github-copilot",
"VScode-MachineId": randHex(64),
"VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12),
"X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12),
}
return h
}
func randHex(n int) string {
const hex = "0123456789abcdef"
b := make([]byte, n)
for i := range b {
b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)]
}
return string(b)
}
// --- Codex-style code completion ---
// CodeCompletion implements CodeCompleter; returns up to n suggestions.
func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) {
if strings.TrimSpace(c.apiKey) == "" {
return nil, errors.New("missing Copilot API key")
}
if err := c.ensureSession(ctx); err != nil {
return nil, err
}
if n <= 0 {
n = 1
}
maxTokens := 500
body := map[string]any{
"extra": map[string]any{
"language": language,
"next_indent": 0,
"prompt_tokens": 500,
"suffix_tokens": 400,
"trim_by_indentation": true,
},
"max_tokens": maxTokens,
"n": n,
"nwo": "hexai",
"prompt": prompt,
"stop": []string{"\n\n"},
"stream": true,
"suffix": suffix,
"temperature": temperature,
"top_p": 1,
}
buf, _ := json.Marshal(body)
url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions"
resp, err := c.postJSON(ctx, url, buf, c.headersGhost())
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode)
}
// Read all and parse lines that start with "data: " accumulating by index
raw, _ := io.ReadAll(resp.Body)
byIndex := make(map[int]string)
lines := strings.Split(string(raw), "\n")
for _, ln := range lines {
if !strings.HasPrefix(ln, "data: ") {
continue
}
var evt struct {
Choices []struct {
Index int `json:"index"`
Text string `json:"text"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil {
continue
}
for _, ch := range evt.Choices {
byIndex[ch.Index] += ch.Text
}
}
out := make([]string, 0, len(byIndex))
for i := 0; i < n; i++ {
if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" {
out = append(out, s)
}
}
return out, nil
}
// newLineDataReader wraps a streaming body and exposes a JSON decoder that
// decodes successive objects from lines prefixed by "data: ".
// (no streaming decoder needed; we parse whole body lines)
// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/logging"
)
// ollamaClient implements Client against a local Ollama server.
type ollamaClient struct {
httpClient *http.Client
baseURL string
defaultModel string
chatLogger logging.ChatLogger
defaultTemperature *float64
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Stream bool `json:"stream"`
Options any `json:"options,omitempty"`
}
type ollamaChatResponse struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
// Constructor (kept among the first functions by convention)
func newOllama(baseURL, model string, defaultTemp *float64) Client {
if strings.TrimSpace(baseURL) == "" {
baseURL = "http://localhost:11434"
}
if strings.TrimSpace(model) == "" {
model = "qwen3-coder:30b-a3b-q4_K_M`"
}
return ollamaClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: model,
chatLogger: logging.NewChatLogger("ollama"),
defaultTemperature: defaultTemp,
}
}
func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
o := Options{Model: c.defaultModel}
for _, opt := range opts {
opt(&o)
}
if o.Model == "" {
o.Model = c.defaultModel
}
start := time.Now()
c.logStart(false, o, messages)
req := buildOllamaRequest(o, messages, c.defaultTemperature, false)
body, err := json.Marshal(req)
if err != nil {
return "", err
}
endpoint := c.baseURL + "/api/chat"
logging.Logf("llm/ollama ", "POST %s", endpoint)
resp, err := c.doJSON(ctx, endpoint, body)
if err != nil {
logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return "", err
}
defer resp.Body.Close()
if err := handleOllamaNon2xx(resp, start); err != nil {
return "", err
}
var out ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return "", err
}
if strings.TrimSpace(out.Message.Content) == "" {
logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
return "", errors.New("ollama: empty content")
}
content := out.Message.Content
logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
return content, nil
}
// Provider metadata
func (c ollamaClient) Name() string { return "ollama" }
func (c ollamaClient) DefaultModel() string { return c.defaultModel }
// Streaming support (optional)
func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
o := Options{Model: c.defaultModel}
for _, opt := range opts {
opt(&o)
}
if o.Model == "" {
o.Model = c.defaultModel
}
start := time.Now()
c.logStart(true, o, messages)
req := buildOllamaRequest(o, messages, c.defaultTemperature, true)
body, err := json.Marshal(req)
if err != nil {
return err
}
endpoint := c.baseURL + "/api/chat"
logging.Logf("llm/ollama ", "POST %s (stream)", endpoint)
resp, err := c.doJSON(ctx, endpoint, body)
if err != nil {
logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return err
}
defer resp.Body.Close()
if err := handleOllamaNon2xx(resp, start); err != nil {
return err
}
dec := json.NewDecoder(resp.Body)
for {
var ev ollamaChatResponse
if err := dec.Decode(&ev); err != nil {
if errors.Is(err, io.EOF) {
break
}
logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return err
}
if strings.TrimSpace(ev.Error) != "" {
logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase)
return fmt.Errorf("ollama stream error: %s", ev.Error)
}
if s := ev.Message.Content; strings.TrimSpace(s) != "" {
onDelta(s)
}
if ev.Done {
break
}
}
logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start))
return nil
}
// helpers to keep methods small
func (c ollamaClient) logStart(stream bool, o Options, messages []Message) {
logMessages := make([]struct{ Role, Content string }, len(messages))
for i, m := range messages {
logMessages[i] = struct{ Role, Content string }{m.Role, m.Content}
}
c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
}
func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest {
req := ollamaChatRequest{Model: o.Model, Stream: stream}
req.Messages = make([]oaMessage, len(messages))
for i, m := range messages {
req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
}
optsMap := map[string]any{}
if o.Temperature != 0 {
optsMap["temperature"] = o.Temperature
} else if defaultTemp != nil {
optsMap["temperature"] = *defaultTemp
}
if o.MaxTokens > 0 {
optsMap["num_predict"] = o.MaxTokens
}
if len(o.Stop) > 0 {
optsMap["stop"] = o.Stop
}
if len(optsMap) > 0 {
req.Options = optsMap
}
return req
}
func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.httpClient.Do(req)
}
func handleOllamaNon2xx(resp *http.Response, start time.Time) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
var apiErr ollamaChatResponse
_ = json.NewDecoder(resp.Body).Decode(&apiErr)
if strings.TrimSpace(apiErr.Error) != "" {
logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase)
return fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode)
}
logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
return fmt.Errorf("ollama http error: status %d", resp.StatusCode)
}
// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging.
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/logging"
)
// openAIClient implements Client against OpenAI's Chat Completions API.
type openAIClient struct {
httpClient *http.Client
apiKey string
baseURL string
defaultModel string
chatLogger logging.ChatLogger
defaultTemperature *float64
}
type oaChatRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type oaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type oaChatResponse struct {
Choices []struct {
Index int `json:"index"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Param any `json:"param"`
Code any `json:"code"`
} `json:"error,omitempty"`
}
// Streaming response chunk type (SSE)
type oaStreamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Param any `json:"param"`
Code any `json:"code"`
} `json:"error,omitempty"`
}
// Constructor (kept among the first functions by convention)
// newOpenAI constructs an OpenAI client using explicit configuration values.
// The apiKey may be empty; calls will fail until a valid key is supplied.
func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client {
if strings.TrimSpace(baseURL) == "" {
baseURL = "https://api.openai.com/v1"
}
if strings.TrimSpace(model) == "" {
model = "gpt-4.1"
}
return openAIClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
baseURL: baseURL,
defaultModel: model,
chatLogger: logging.NewChatLogger("openai"),
defaultTemperature: defaultTemp,
}
}
func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
if c.apiKey == "" {
return nilStringErr("missing OpenAI API key")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
opt(&o)
}
if o.Model == "" {
o.Model = c.defaultModel
}
start := time.Now()
c.logStart(false, o, messages)
req := buildOAChatRequest(o, messages, c.defaultTemperature, false)
body, err := json.Marshal(req)
if err != nil {
c.logf("marshal error: %v", err)
return "", err
}
endpoint := c.baseURL + "/chat/completions"
logging.Logf("llm/openai ", "POST %s", endpoint)
resp, err := c.doJSON(ctx, endpoint, body, map[string]string{
"Authorization": "Bearer " + c.apiKey,
})
if err != nil {
logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return "", err
}
defer resp.Body.Close()
if err := handleOpenAINon2xx(resp, start); err != nil {
return "", err
}
out, err := decodeOpenAIChat(resp, start)
if err != nil {
return "", err
}
if len(out.Choices) == 0 {
logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
return "", errors.New("openai: no choices returned")
}
content := out.Choices[0].Message.Content
logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
return content, nil
}
// Provider metadata
func (c openAIClient) Name() string { return "openai" }
func (c openAIClient) DefaultModel() string { return c.defaultModel }
// Streaming support (optional)
func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
if c.apiKey == "" {
return errors.New("missing OpenAI API key")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
opt(&o)
}
if o.Model == "" {
o.Model = c.defaultModel
}
start := time.Now()
c.logStart(true, o, messages)
req := buildOAChatRequest(o, messages, c.defaultTemperature, true)
body, err := json.Marshal(req)
if err != nil {
c.logf("marshal error: %v", err)
return err
}
endpoint := c.baseURL + "/chat/completions"
logging.Logf("llm/openai ", "POST %s (stream)", endpoint)
resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{
"Authorization": "Bearer " + c.apiKey,
}, "text/event-stream")
if err != nil {
logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return err
}
defer resp.Body.Close()
if err := handleOpenAINon2xx(resp, start); err != nil {
return err
}
if err := parseOpenAIStream(resp, start, onDelta); err != nil {
return err
}
logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start))
return nil
}
// Private helpers
func (c openAIClient) logf(format string, args ...any) { logging.Logf("llm/openai ", format, args...) }
// helpers extracted to keep methods small
func (c openAIClient) logStart(stream bool, o Options, messages []Message) {
logMessages := make([]struct{ Role, Content string }, len(messages))
for i, m := range messages {
logMessages[i] = struct{ Role, Content string }{m.Role, m.Content}
}
c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
}
func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest {
req := oaChatRequest{Model: o.Model, Stream: stream}
req.Messages = make([]oaMessage, len(messages))
for i, m := range messages {
req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
}
if o.Temperature != 0 {
req.Temperature = &o.Temperature
} else if defaultTemp != nil {
t := *defaultTemp
req.Temperature = &t
}
if o.MaxTokens > 0 {
req.MaxTokens = &o.MaxTokens
}
if len(o.Stop) > 0 {
req.Stop = o.Stop
}
return req
}
func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
return c.httpClient.Do(req)
}
func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", accept)
for k, v := range headers {
req.Header.Set(k, v)
}
return c.httpClient.Do(req)
}
func handleOpenAINon2xx(resp *http.Response, start time.Time) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
var apiErr oaChatResponse
_ = json.NewDecoder(resp.Body).Decode(&apiErr)
if apiErr.Error != nil && apiErr.Error.Message != "" {
logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
return fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
}
logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
return fmt.Errorf("openai http error: status %d", resp.StatusCode)
}
func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) {
var out oaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return oaChatResponse{}, err
}
return out, nil
}
func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error {
// Parse SSE: lines starting with "data: " containing JSON or [DONE]
scanner := bufio.NewScanner(resp.Body)
const maxBuf = 1024 * 1024
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxBuf)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if strings.TrimSpace(payload) == "[DONE]" {
break
}
var chunk oaStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Error != nil && chunk.Error.Message != "" {
logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase)
return fmt.Errorf("openai stream error: %s", chunk.Error.Message)
}
for _, ch := range chunk.Choices {
if ch.Delta.Content != "" {
onDelta(ch.Delta.Content)
}
}
}
if err := scanner.Err(); err != nil {
logging.Logf("llm/openai ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return err
}
return nil
}
// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config.
package llm
import (
"context"
"errors"
"strings"
)
// Message represents a chat-style prompt message.
type Message struct {
Role string
Content string
}
// Client is a minimal LLM provider interface.
// Future providers (Ollama, etc.) should implement this.
type Client interface {
// Chat sends chat messages and returns the assistant text.
Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
// Name returns the provider's short name (e.g., "openai", "ollama").
Name() string
// DefaultModel returns the configured default model name.
DefaultModel() string
}
// Streamer is an optional interface that providers may implement to support
// token-by-token streaming responses. Callers can type-assert to Streamer and
// fall back to Client.Chat when not implemented.
type Streamer interface {
// ChatStream sends chat messages and invokes onDelta with incremental text
// chunks as they are produced by the model. Implementations should call
// onDelta with empty strings sparingly (prefer only non-empty chunks).
ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error
}
// CodeCompleter is an optional interface for providers that support a
// prompt/suffix code-completion API (e.g., Copilot Codex endpoint). Clients
// can type-assert to this and prefer it over chat when available.
type CodeCompleter interface {
// CodeCompletion requests up to n suggestions given a left-hand prompt and
// right-hand suffix around the cursor. Language is advisory and may be
// ignored. Temperature applies when provider supports it.
CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error)
}
// Options for a request. Providers may ignore unsupported fields.
type Options struct {
Model string
Temperature float64
MaxTokens int
Stop []string
}
// RequestOption mutates Options.
type RequestOption func(*Options)
func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } }
func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } }
func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } }
func WithStop(stop ...string) RequestOption {
return func(o *Options) { o.Stop = append([]string{}, stop...) }
}
// Config defines provider configuration read from the Hexai config file.
type Config struct {
Provider string
// OpenAI options
OpenAIBaseURL string
OpenAIModel string
OpenAITemperature *float64
// Ollama options
OllamaBaseURL string
OllamaModel string
OllamaTemperature *float64
// Copilot options
CopilotBaseURL string
CopilotModel string
CopilotTemperature *float64
}
// NewFromConfig creates an LLM client using only the supplied configuration.
// The OpenAI API key is supplied separately and may be read from the environment
// by the caller; other environment-based configuration is not used.
func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) {
p := strings.ToLower(strings.TrimSpace(cfg.Provider))
if p == "" {
p = "openai"
}
switch p {
case "openai":
if strings.TrimSpace(openAIAPIKey) == "" {
return nil, errors.New("missing OPENAI_API_KEY for provider openai")
}
// Set coding-friendly default temperature if none provided
if cfg.OpenAITemperature == nil {
t := 0.2
cfg.OpenAITemperature = &t
}
return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil
case "ollama":
if cfg.OllamaTemperature == nil {
t := 0.2
cfg.OllamaTemperature = &t
}
return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil
case "copilot":
if strings.TrimSpace(copilotAPIKey) == "" {
return nil, errors.New("missing COPILOT_API_KEY for provider copilot")
}
if cfg.CopilotTemperature == nil {
t := 0.2
cfg.CopilotTemperature = &t
}
return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil
default:
return nil, errors.New("unknown LLM provider: " + p)
}
}
package llm
import "errors"
// small helper to keep return type consistent
func nilStringErr(msg string) (string, error) { return "", errors.New(msg) }
package llmutils
import (
"os"
"strings"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
)
// NewClientFromApp builds an llm.Client using app config and environment keys.
func NewClientFromApp(cfg appconfig.App) (llm.Client, error) {
llmCfg := llm.Config{
Provider: cfg.Provider,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
OpenAITemperature: cfg.OpenAITemperature,
OllamaBaseURL: cfg.OllamaBaseURL,
OllamaModel: cfg.OllamaModel,
OllamaTemperature: cfg.OllamaTemperature,
CopilotBaseURL: cfg.CopilotBaseURL,
CopilotModel: cfg.CopilotModel,
CopilotTemperature: cfg.CopilotTemperature,
}
oaKey := os.Getenv("HEXAI_OPENAI_API_KEY")
if strings.TrimSpace(oaKey) == "" {
oaKey = os.Getenv("OPENAI_API_KEY")
}
cpKey := os.Getenv("HEXAI_COPILOT_API_KEY")
if strings.TrimSpace(cpKey) == "" {
cpKey = os.Getenv("COPILOT_API_KEY")
}
return llm.NewFromConfig(llmCfg, oaKey, cpKey)
}
package logging
// ChatLogger provides a structured way to log chat interactions.
type ChatLogger struct {
Provider string
}
// NewChatLogger creates a new ChatLogger for a given provider.
func NewChatLogger(provider string) ChatLogger {
return ChatLogger{Provider: provider}
}
// LogStart logs the beginning of a chat or stream interaction.
func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens int, stop []string, messages []struct {
Role string
Content string
},
) {
chatOrStream := "chat"
if stream {
chatOrStream = "stream"
}
Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d",
chatOrStream, model, temp, maxTokens, len(stop), len(messages))
for i, m := range messages {
Logf("llm/"+cl.Provider+" ", "msg[%d] role=%s size=%d preview=%s%s%s",
i, m.Role, len(m.Content), AnsiCyan, PreviewForLog(m.Content), AnsiBase)
}
}
// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation.
package logging
import (
"fmt"
"log"
)
// ANSI color utilities shared across Hexai.
const (
AnsiBgBlack = "\x1b[40m"
AnsiGrey = "\x1b[90m"
AnsiCyan = "\x1b[36m"
AnsiGreen = "\x1b[32m"
AnsiYellow = "\x1b[33m"
AnsiRed = "\x1b[31m"
AnsiReset = "\x1b[0m"
)
// AnsiBase is the default style: black background + grey foreground.
const AnsiBase = AnsiBgBlack + AnsiGrey
// singleton logger used across the codebase
var std *log.Logger
// Bind sets the underlying standard logger to use for Logf.
func Bind(l *log.Logger) { std = l }
// Logf prints a formatted message with a module prefix and base ANSI style.
func Logf(prefix, format string, args ...any) {
if std == nil {
return
}
msg := fmt.Sprintf(format, args...)
std.Print(AnsiBase + prefix + msg + AnsiReset)
}
// Logging configuration for previews (shared)
var logPreviewLimit int // 0 means unlimited
// SetLogPreviewLimit sets the maximum number of characters to log for
// request/response previews. Set to 0 for unlimited.
func SetLogPreviewLimit(n int) { logPreviewLimit = n }
// PreviewForLog returns the string truncated to the configured preview limit.
func PreviewForLog(s string) string {
if logPreviewLimit > 0 {
if len(s) <= logPreviewLimit {
return s
}
return s[:logPreviewLimit] + "…"
}
return s
}
// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic.
package lsp
import (
"strings"
"codeberg.org/snonux/hexai/internal/logging"
)
// buildAdditionalContext builds extra context messages based on the configured mode.
// Modes:
// - minimal: no extra context
// - window: include a window of lines around the cursor
// - file-on-new-func: include full file only when defining a new function
// - always-full: always include the full file
func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) {
mode := s.contextMode
switch mode {
case "minimal":
return "", false
case "window":
return s.windowContext(uri, pos), true
case "file-on-new-func":
if newFunc {
return s.fullFileContext(uri), true
}
return "", false
case "always-full":
return s.fullFileContext(uri), true
default:
// fallback to minimal if unknown
return "", false
}
}
func (s *Server) windowContext(uri string, pos Position) string {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri)
return ""
}
n := len(d.lines)
half := s.windowLines / 2
start := pos.Line - half
if start < 0 {
start = 0
}
end := pos.Line + half + 1
if end > n {
end = n
}
text := strings.Join(d.lines[start:end], "\n")
return truncateToApproxTokens(text, s.maxContextTokens)
}
func (s *Server) fullFileContext(uri string) string {
d := s.getDocument(uri)
if d == nil {
logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri)
return ""
}
return truncateToApproxTokens(d.text, s.maxContextTokens)
}
// truncateToApproxTokens naively truncates the input to fit approx N tokens.
// Uses 4 chars/token heuristic for speed and determinism.
func truncateToApproxTokens(text string, maxTokens int) string {
if maxTokens <= 0 {
return ""
}
maxChars := maxTokens * 4
if len(text) <= maxChars {
return text
}
// try to cut on a line boundary near maxChars
cut := maxChars
if cut > len(text) {
cut = len(text)
}
if i := strings.LastIndex(text[:cut], "\n"); i > 0 {
cut = i
}
return text[:cut]
}
// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits.
package lsp
import (
"strings"
"time"
)
type document struct {
uri string
text string
lines []string
}
func (s *Server) setDocument(uri, text string) {
s.mu.Lock()
defer s.mu.Unlock()
s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)}
}
func (s *Server) deleteDocument(uri string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.docs, uri)
}
func (s *Server) markActivity() {
s.mu.Lock()
s.lastInput = time.Now()
s.mu.Unlock()
}
func (s *Server) getDocument(uri string) *document {
s.mu.RLock()
defer s.mu.RUnlock()
return s.docs[uri]
}
// splitLines splits the input string into lines, normalizing line endings to '\n'.
func splitLines(sx string) []string {
sx = strings.ReplaceAll(sx, "\r\n", "\n")
return strings.Split(sx, "\n")
}
func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
return "", "", "", ""
}
idx := pos.Line
if idx < 0 {
idx = 0
}
if idx >= len(d.lines) {
idx = len(d.lines) - 1
}
current = d.lines[idx]
if idx-1 >= 0 {
above = d.lines[idx-1]
}
if idx+1 < len(d.lines) {
below = d.lines[idx+1]
}
for i := idx; i >= 0; i-- {
line := strings.TrimSpace(d.lines[i])
if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) {
funcCtx = line
break
}
}
return above, current, below, funcCtx
}
// isDefiningNewFunction returns true when the cursor appears to be within
// a function declaration/signature and before the opening '{' of the body.
// Heuristic: find nearest preceding line containing "func "; ensure no '{'
// appears before the cursor across those lines.
func (s *Server) isDefiningNewFunction(uri string, pos Position) bool {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
return false
}
idx := pos.Line
if idx < 0 {
idx = 0
}
if idx >= len(d.lines) {
idx = len(d.lines) - 1
}
// Find signature start
sigStart := -1
for i := idx; i >= 0; i-- {
if strings.Contains(d.lines[i], "func ") {
sigStart = i
break
}
// stop if we hit a closing brace which likely ends a previous block
if strings.Contains(d.lines[i], "}") {
break
}
}
if sigStart == -1 {
return false
}
// Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body
for i := sigStart; i <= idx; i++ {
line := d.lines[i]
brace := strings.Index(line, "{")
if brace >= 0 {
if i < idx {
return false // body started on a previous line
}
// same line as cursor: if brace position < cursor character, then already in body
if pos.Character > brace {
return false
}
}
}
return true
}
func hasAny(s string, needles []string) bool {
for _, n := range needles {
if strings.Contains(s, n) {
return true
}
}
return false
}
func trimLen(s string) string {
s = strings.TrimSpace(s)
if len(s) > 200 {
return s[:200] + "…"
}
return s
}
func firstLine(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
if idx := strings.IndexByte(s, '\n'); idx >= 0 {
return s[:idx]
}
return s
}
// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled.
package lsp
import (
"encoding/json"
"fmt"
"strings"
)
func (s *Server) handle(req Request) {
if h, ok := s.handlers[req.Method]; ok {
h(req)
return
}
if len(req.ID) != 0 {
s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)})
}
}
// handleInitialize moved to handlers_init.go
// llmRequestOpts moved to handlers_utils.go
// instructionFromSelection extracts the first instruction from selection text.
// Preference order on each line: strict ;text; marker (no inner spaces), then
// a line comment (//, #, --). Returns the instruction string and the selection
// text cleaned of the matched instruction marker or comment.
func instructionFromSelection(sel string) (string, string) {
lines := splitLines(sel)
for idx, line := range lines {
if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" {
lines[idx] = cleaned
return instr, strings.Join(lines, "\n")
}
}
return "", sel
}
// findFirstInstructionInLine returns the earliest instruction marker on the
// line and the line with that marker removed. Supported markers, ordered by
// earliest byte offset in the line:
// - ;text; (strict, no space after first ';' or before last ';')
// - /* text */ (single-line only)
// - <!-- text --> (single-line only)
// - // text
// - # text
// - -- text
func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) {
type cand struct {
start, end int
text string
}
cands := []cand{}
if t, l, r, ok := findStrictInlineTag(line); ok {
cands = append(cands, cand{start: l, end: r, text: t})
}
if i := strings.Index(line, "/*"); i >= 0 {
if j := strings.Index(line[i+2:], "*/"); j >= 0 {
start := i
end := i + 2 + j + 2
text := strings.TrimSpace(line[i+2 : i+2+j])
cands = append(cands, cand{start: start, end: end, text: text})
}
}
if i := strings.Index(line, "<!--"); i >= 0 {
if j := strings.Index(line[i+4:], "-->"); j >= 0 {
start := i
end := i + 4 + j + 3
text := strings.TrimSpace(line[i+4 : i+4+j])
cands = append(cands, cand{start: start, end: end, text: text})
}
}
if i := strings.Index(line, "//"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
}
if i := strings.Index(line, "#"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])})
}
if i := strings.Index(line, "--"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
}
if len(cands) == 0 {
return "", line, false
}
// pick earliest start index
best := cands[0]
for _, c := range cands[1:] {
if c.start >= 0 && (best.start < 0 || c.start < best.start) {
best = c
}
}
cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t")
return best.text, cleaned, true
}
// diagnosticsInRange parses the CodeAction context and returns diagnostics
// that overlap the given selection range. If the context is missing or does
// not contain diagnostics, returns an empty slice.
// CodeAction-related handlers and helpers moved to handlers_codeaction.go
// extractRangeText moved to handlers_utils.go
// handleInitialized moved to handlers_init.go
// handleShutdown moved to handlers_init.go
// handleExit moved to handlers_init.go
// handleDidOpen moved to handlers_document.go
// handleDidChange moved to handlers_document.go
// handleDidClose moved to handlers_document.go
// handleCompletion moved to handlers_completion.go
func (s *Server) reply(id json.RawMessage, result any, err *RespError) {
resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err}
s.writeMessage(resp)
}
// docBeforeAfter returns the full document text split at the given position.
// The returned strings are the text before the cursor (inclusive of anything
// left of the position) and the text after the cursor.
// docBeforeAfter moved to handlers_document.go
// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter
// if provided by the client; when absent it returns zeros.
// extractTriggerInfo moved to handlers_completion.go
// --- in-editor chat (";C ...") ---
// detectAndHandleChat scans the current document for any line that starts with
// ";C" and appears to be awaiting a response (i.e., followed by a blank line
// and no non-empty answer line yet). If found, it asks the LLM and inserts the
// answer below the blank line, leaving exactly one empty line between prompt
// and response.
// detectAndHandleChat moved to handlers_document.go
// applyChatEdits removes the triggering punctuation at end of the line and
// inserts two newlines followed by a new line with the response prefixed.
// applyChatEdits moved to handlers_document.go
// buildChatHistory walks upwards from the current line to collect the most recent
// Q/A pairs in the in-editor transcript. It returns messages in chronological order
// ending with the current user prompt. Limits to a small number of pairs to control tokens.
// buildChatHistory moved to handlers_document.go
// stripTrailingTrigger removes a single trailing punctuation from the set
// [?,!,:] or both semicolons if present at end, mirroring the inline trigger rules.
// stripTrailingTrigger moved to handlers_document.go
// clientApplyEdit sends a workspace/applyEdit request to the client.
// clientApplyEdit moved to handlers_document.go
// nextReqID returns a unique json.RawMessage id for server-initiated requests.
// nextReqID moved to handlers_document.go
// --- completion helpers ---
// buildDocString moved to handlers_completion.go
// logCompletionContext moved to handlers_completion.go
// tryLLMCompletion moved to handlers_completion.go
// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion.
// parseManualInvoke moved to handlers_completion.go
// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL.
// shouldSuppressForChatTriggerEOL moved to handlers_completion.go
// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply.
// prefixHeuristicAllows moved to handlers_completion.go
// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
// tryProviderNativeCompletion moved to handlers_completion.go
// buildCompletionMessages constructs the LLM messages for completion.
// buildCompletionMessages moved to handlers_completion.go
// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules.
// postProcessCompletion moved to handlers_completion.go
// busyCompletionItem builds a visible, non-inserting completion item indicating
// that an LLM request is already in flight.
// removed: previous single in-flight LLM busy gate and busy item
// --- small completion cache (last ~10 entries) ---
func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string {
// Normalize left-of-cursor by trimming trailing spaces/tabs
idx := p.Position.Character
if idx > len(current) {
idx = len(current)
}
left := strings.TrimRight(current[:idx], " \t")
right := ""
if idx < len(current) {
right = current[idx:]
}
prov := ""
model := ""
if s.llmClient != nil {
prov = s.llmClient.Name()
model = s.llmClient.DefaultModel()
}
temp := ""
if s.codingTemperature != nil {
temp = fmt.Sprintf("%.3f", *s.codingTemperature)
}
extra := ""
if hasExtra {
extra = strings.TrimSpace(extraText)
}
// Compose a key from essential context parts
return strings.Join([]string{
"v1", // version for future-proofing
prov,
model,
temp,
p.TextDocument.URI,
fmt.Sprintf("%d:%d", p.Position.Line, len(left)),
above,
left,
right,
below,
funcCtx,
fmt.Sprintf("params=%t", inParams),
extra,
}, "\x1f") // use unit separator to avoid collisions
}
func (s *Server) completionCacheGet(key string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.compCache[key]
if !ok {
return "", false
}
// move to most-recent
s.compCacheTouchLocked(key)
return v, true
}
func (s *Server) completionCachePut(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.compCache == nil {
s.compCache = make(map[string]string)
}
if _, exists := s.compCache[key]; !exists {
s.compCacheOrder = append(s.compCacheOrder, key)
s.compCache[key] = value
if len(s.compCacheOrder) > 10 {
// evict oldest
old := s.compCacheOrder[0]
s.compCacheOrder = s.compCacheOrder[1:]
delete(s.compCache, old)
}
return
}
// update existing and mark most-recent
s.compCache[key] = value
s.compCacheTouchLocked(key)
}
func (s *Server) compCacheTouchLocked(key string) {
// assumes s.mu is held
// remove any existing occurrence of key in order slice
idx := -1
for i, k := range s.compCacheOrder {
if k == key {
idx = i
break
}
}
if idx >= 0 {
s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...)
}
s.compCacheOrder = append(s.compCacheOrder, key)
}
// isTriggerEvent returns true when the completion request appears to be caused
// by typing one of our configured trigger characters. It checks the LSP
// CompletionContext if provided and also falls back to inspecting the character
// immediately to the left of the cursor.
func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
// 1) Inspect LSP completion context if present
if p.Context != nil {
var ctx struct {
TriggerKind int `json:"triggerKind"`
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
_ = json.Unmarshal(raw, &ctx)
} else {
b, _ := json.Marshal(p.Context)
_ = json.Unmarshal(b, &ctx)
}
// If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'),
// do not treat as a trigger source.
if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) {
return false
}
// TriggerKind 1 = Invoked (manual). Always allow manual invoke.
if ctx.TriggerKind == 1 {
return true
}
// TriggerKind 2 is TriggerCharacter per LSP spec
if ctx.TriggerKind == 2 {
if ctx.TriggerCharacter != "" {
for _, c := range s.triggerChars {
if c == ctx.TriggerCharacter {
return true
}
}
return false
}
// No character provided but reported as TriggerCharacter; be conservative
return false
}
// For TriggerForIncomplete (3), require manual char check below
}
// 2) Fallback: check the character immediately prior to cursor
idx := p.Position.Character
if idx <= 0 || idx > len(current) {
return false
}
// Bare double-open should not trigger via fallback char either (only when configured)
if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) {
return false
}
ch := string(current[idx-1])
for _, c := range s.triggerChars {
if c == ch {
return true
}
}
return false
}
func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem {
te, filter := computeTextEditAndFilter(cleaned, inParams, current, p)
rm := s.collectPromptRemovalEdits(p.TextDocument.URI)
label := labelForCompletion(cleaned, filter)
detail := "Hexai LLM completion"
if s.llmClient != nil {
detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel()
}
return []CompletionItem{{
Label: label,
Kind: 1,
Detail: detail,
InsertTextFormat: 1,
FilterText: strings.TrimLeft(filter, " \t"),
TextEdit: te,
AdditionalTextEdits: rm,
SortText: "0000",
Documentation: docStr,
}}
}
// small helpers to keep tryLLMCompletion short
// LLM stats helpers moved to handlers_utils.go
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
// Supported form (inclusive):
// - ";...;" where there is no space immediately after the first ';'
// and no space immediately before the last ';'. An optional single space
// after the trailing ';' is also removed for cleanliness.
//
// Multiple markers per line are supported.
// Inline prompt removal helpers moved to handlers_utils.go
// inParamList moved to handlers_utils.go
// buildPrompts moved to handlers_utils.go
// computeTextEditAndFilter moved to handlers_utils.go
// computeWordStart moved to handlers_utils.go
// isIdentChar moved to handlers_utils.go
// lineHasInlinePrompt returns true if the line contains an inline strict
// semicolon marker ;text; (no spaces at boundaries) or a double-semicolon
// pattern recognized by hasDoubleSemicolonTrigger.
// lineHasInlinePrompt moved to handlers_utils.go
// leadingIndent returns the run of leading spaces/tabs from the provided line.
// leadingIndent moved to handlers_utils.go
// applyIndent prefixes each non-empty line of suggestion with the given indent
// unless it already starts with that indent.
// applyIndent moved to handlers_utils.go
// isBareDoubleSemicolon reports whether the line contains a standalone
// double-semicolon marker with no inline content (";;" possibly with only
// whitespace after it). It explicitly excludes the valid form ";;text;".
// isBareDoubleSemicolon moved to handlers_utils.go
// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g.,
// "name :=") from the beginning of the model suggestion when that same prefix
// already appears immediately to the left of the cursor on the current line.
// Also handles simple '=' assignments.
// stripDuplicateAssignmentPrefix moved to handlers_utils.go
// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated
// at the beginning of its suggestion. It compares the entire text to the left of the
// cursor (prefixBeforeCursor) against the suggestion, trimming whitespace appropriately,
// and strips the longest sensible overlap. This prevents cases like:
//
// prefix: "func New "
// suggestion:"func New() *Type"
//
// resulting in duplicates like "func New func New() *Type".
// stripDuplicateGeneralPrefix moved to handlers_utils.go
// isIdentBoundary moved to handlers_utils.go
// stripCodeFences removes surrounding Markdown code fences from a model
// response when the entire output is wrapped, e.g. starting with "```go" or
// "```" and ending with "```". It returns the inner content unchanged.
// stripCodeFences moved to handlers_utils.go
// stripInlineCodeSpan returns only the contents of the first inline backtick
// code span if present, e.g., "some text `x := y()` more" -> "x := y()".
// If no matching pair of backticks exists, it returns the input unchanged.
// This is intended for code completion responses where the model may wrap a
// small snippet in single backticks among prose.
// stripInlineCodeSpan moved to handlers_utils.go
// labelForCompletion moved to handlers_utils.go
func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem {
return []CompletionItem{{
Label: "hexai-complete",
Kind: 1,
Detail: "dummy completion",
InsertText: "hexai",
SortText: "9999",
Documentation: docStr,
}}
}
// Summary: Code Action handlers and helpers split from handlers.go for clarity.
package lsp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) handleCodeAction(req Request) {
var p CodeActionParams
if err := json.Unmarshal(req.Params, &p); err != nil {
if len(req.ID) != 0 {
s.reply(req.ID, []CodeAction{}, nil)
}
return
}
d := s.getDocument(p.TextDocument.URI)
if d == nil || len(d.lines) == 0 || s.llmClient == nil {
if len(req.ID) != 0 {
s.reply(req.ID, []CodeAction{}, nil)
}
return
}
sel := extractRangeText(d, p.Range)
actions := make([]CodeAction, 0, 4)
if a := s.buildRewriteCodeAction(p, sel); a != nil {
actions = append(actions, *a)
}
if a := s.buildDiagnosticsCodeAction(p, sel); a != nil {
actions = append(actions, *a)
}
if a := s.buildDocumentCodeAction(p, sel); a != nil {
actions = append(actions, *a)
}
if a := s.buildGoUnitTestCodeAction(p); a != nil {
actions = append(actions, *a)
}
if len(req.ID) != 0 {
s.reply(req.ID, actions, nil)
}
}
func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction {
if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" {
payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
Instruction string `json:"instruction"`
Selection string `json:"selection"`
}{Type: "rewrite", URI: p.TextDocument.URI, Range: p.Range, Instruction: instr, Selection: cleaned}
raw, _ := json.Marshal(payload)
ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw}
return &ca
}
return nil
}
func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction {
diags := s.diagnosticsInRange(p.Context, p.Range)
if len(diags) == 0 {
return nil
}
payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
Selection string `json:"selection"`
Diagnostics []Diagnostic `json:"diagnostics"`
}{Type: "diagnostics", URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags}
raw, _ := json.Marshal(payload)
ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Data: raw}
return &ca
}
func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) {
if s.llmClient == nil || len(ca.Data) == 0 {
return ca, false
}
var payload struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
Instruction string `json:"instruction,omitempty"`
Selection string `json:"selection"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
if err := json.Unmarshal(ca.Data, &payload); err != nil {
return ca, false
}
switch payload.Type {
case "rewrite":
sys := s.promptRewriteSystem
user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil {
if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &edit
return ca, true
}
} else {
logging.Logf("lsp ", "codeAction rewrite llm error: %v", err)
}
case "diagnostics":
sys := s.promptDiagnosticsSystem
var b strings.Builder
for i, dgn := range payload.Diagnostics {
if dgn.Source != "" {
fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message)
} else {
fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message)
}
}
diagList := b.String()
user := renderTemplate(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection})
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil {
if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &edit
return ca, true
}
} else {
logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err)
}
case "document":
sys := s.promptDocumentSystem
user := renderTemplate(s.promptDocumentUser, map[string]string{"selection": payload.Selection})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil {
if out := stripCodeFences(strings.TrimSpace(text)); out != "" {
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &edit
return ca, true
}
} else {
logging.Logf("lsp ", "codeAction document llm error: %v", err)
}
case "go_test":
if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok {
ca.Edit = &edit
// After edit is applied, ask client to jump to new test function
ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}}
// Also send a server-initiated showDocument shortly after resolve to cover
// clients that do not execute commands from code actions.
s.deferShowDocument(jumpURI, jumpRange)
return ca, true
}
}
return ca, false
}
func (s *Server) handleCodeActionResolve(req Request) {
var ca CodeAction
if err := json.Unmarshal(req.Params, &ca); err != nil {
if len(req.ID) != 0 {
s.reply(req.ID, ca, nil)
}
return
}
if resolved, ok := s.resolveCodeAction(ca); ok {
s.reply(req.ID, resolved, nil)
return
}
s.reply(req.ID, ca, nil)
}
// diagnosticsInRange parses the CodeAction context and returns diagnostics
// that overlap the given selection range. If the context is missing or does
// not contain diagnostics, returns an empty slice.
func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic {
if len(ctxRaw) == 0 {
return nil
}
var ctx CodeActionContext
if err := json.Unmarshal(ctxRaw, &ctx); err != nil {
return nil
}
if len(ctx.Diagnostics) == 0 {
return nil
}
out := make([]Diagnostic, 0, len(ctx.Diagnostics))
for _, d := range ctx.Diagnostics {
if rangesOverlap(d.Range, sel) {
out = append(out, d)
}
}
return out
}
// rangesOverlap reports whether two LSP ranges overlap at all.
func rangesOverlap(a, b Range) bool {
// Normalize ordering
if greaterPos(a.Start, a.End) {
a.Start, a.End = a.End, a.Start
}
if greaterPos(b.Start, b.End) {
b.Start, b.End = b.End, b.Start
}
// a ends before b starts
if lessPos(a.End, b.Start) {
return false
}
// b ends before a starts
if lessPos(b.End, a.Start) {
return false
}
return true
}
func lessPos(p, q Position) bool {
if p.Line != q.Line {
return p.Line < q.Line
}
return p.Character < q.Character
}
func greaterPos(p, q Position) bool {
if p.Line != q.Line {
return p.Line > q.Line
}
return p.Character > q.Character
}
// --- Go unit test code action ---
func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction {
uri := p.TextDocument.URI
if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") {
return nil
}
// Skip if already a _test.go file
if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") {
return nil
}
// Heuristic: only offer when a function context is found above the cursor
_, _, _, funcCtx := s.lineContext(uri, p.Range.Start)
if !strings.Contains(funcCtx, "func ") {
return nil
}
payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
}{Type: "go_test", URI: uri, Range: p.Range}
raw, _ := json.Marshal(payload)
ca := CodeAction{Title: "Hexai: implement unit test", Kind: "quickfix", Data: raw}
return &ca
}
// buildDocumentCodeAction offers to document the selected code by injecting comments.
func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction {
if s.llmClient == nil {
return nil
}
if strings.TrimSpace(sel) == "" {
return nil
}
payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
Selection string `json:"selection"`
}{Type: "document", URI: p.TextDocument.URI, Range: p.Range, Selection: sel}
raw, _ := json.Marshal(payload)
ca := CodeAction{Title: "Hexai: document code", Kind: "refactor.rewrite", Data: raw}
return &ca
}
func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) {
path := strings.TrimPrefix(uri, "file://")
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return WorkspaceEdit{}, "", Range{}, false
}
// Load source text
_, lines := s.loadFileText(uri)
if len(lines) == 0 {
return WorkspaceEdit{}, "", Range{}, false
}
pkg := parseGoPackageName(lines)
fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line)
if fnStart < 0 || fnEnd < fnStart {
return WorkspaceEdit{}, "", Range{}, false
}
funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n")
testFunc := s.generateGoTestFunction(funcCode)
if strings.TrimSpace(testFunc) == "" {
return WorkspaceEdit{}, "", Range{}, false
}
// Determine test file target
testPath := strings.TrimSuffix(path, ".go") + "_test.go"
testURI := "file://" + testPath
// If test file exists, append test at EOF; otherwise, create a new file with package+import
if fileExists(testPath) {
// Build an insertion at end of file
_, tLines := s.loadFileText(testURI)
// Fallback when not open and cannot read: still insert at line 0
lineIdx := 0
col := 0
if len(tLines) > 0 {
lineIdx = len(tLines) - 1
col = len(tLines[lineIdx])
}
var b strings.Builder
// Ensure at least two newlines before the new test
if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) {
b.WriteString("\n\n")
}
b.WriteString(testFunc)
insert := b.String()
edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert}
we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}}
// Compute jump range start
// Count how many prefix newlines added before the test function
prefixNL := 0
if strings.HasPrefix(insert, "\n\n") {
prefixNL = 2
}
startLine := lineIdx + prefixNL
// If we inserted with two newlines and last line wasn't blank, first newline moves to next line
if prefixNL > 0 {
startLine = lineIdx + prefixNL
}
jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}}
return we, testURI, jump, true
}
// Create new file content
var content strings.Builder
if pkg == "" {
pkg = filepath.Base(filepath.Dir(path))
}
content.WriteString("package ")
content.WriteString(pkg)
content.WriteString("\n\n")
content.WriteString("import (\n\t\"testing\"\n)\n\n")
content.WriteString(testFunc)
full := content.String()
// Use documentChanges with create + full content insert
create := CreateFile{Kind: "create", URI: testURI}
tde := TextDocumentEdit{TextDocument: VersionedTextDocumentIdentifier{URI: testURI}, Edits: []TextEdit{{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 0}}, NewText: full}}}
we := WorkspaceEdit{DocumentChanges: []any{create, tde}}
// Find start line of first test function
// Count lines before the substring "func Test"
pre := content.String()
idx := strings.Index(pre, "func Test")
startLine := 0
if idx > 0 {
before := pre[:idx]
startLine = strings.Count(before, "\n")
}
jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}}
return we, testURI, jump, true
}
// loadFileText returns the file content and lines. It prefers the open document; otherwise reads from disk.
func (s *Server) loadFileText(uri string) (string, []string) {
if d := s.getDocument(uri); d != nil {
return d.text, append([]string{}, d.lines...)
}
path := strings.TrimPrefix(uri, "file://")
b, err := os.ReadFile(path)
if err != nil {
return "", nil
}
txt := string(b)
return txt, splitLines(txt)
}
func fileExists(path string) bool {
if _, err := os.Stat(path); err == nil {
return true
}
return false
}
// parseGoPackageName returns the package name from file lines, or empty if not found.
func parseGoPackageName(lines []string) string {
for _, ln := range lines {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, "package ") {
name := strings.TrimSpace(strings.TrimPrefix(t, "package "))
// strip inline comments
if i := strings.Index(name, " "); i >= 0 {
name = name[:i]
}
if i := strings.Index(name, "\t"); i >= 0 {
name = name[:i]
}
if i := strings.Index(name, "//"); i >= 0 {
name = strings.TrimSpace(name[:i])
}
return name
}
}
return ""
}
// findGoFunctionAtLine finds the function enclosing or preceding line idx. Returns start and end line indexes.
func findGoFunctionAtLine(lines []string, idx int) (int, int) {
if idx < 0 {
idx = 0
}
if idx >= len(lines) {
idx = len(lines) - 1
}
// find signature start
start := -1
for i := idx; i >= 0; i-- {
if strings.Contains(lines[i], "func ") {
start = i
break
}
if strings.Contains(lines[i], "}") {
break
}
}
if start == -1 {
return -1, -1
}
// find first '{'
depth := 0
seenOpen := false
for i := start; i < len(lines); i++ {
ln := lines[i]
for j := 0; j < len(ln); j++ {
switch ln[j] {
case '{':
depth++
seenOpen = true
case '}':
if depth > 0 {
depth--
}
if seenOpen && depth == 0 {
return start, i
}
}
}
}
// if never saw '{', assume single-line prototype; return that line
if !seenOpen {
return start, start
}
return start, -1
}
// generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable.
func (s *Server) generateGoTestFunction(funcCode string) string {
if s.llmClient != nil {
sys := s.promptGoTestSystem
user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode})
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil {
cleaned := strings.TrimSpace(stripCodeFences(out))
if cleaned != "" {
return cleaned
}
} else {
logging.Logf("lsp ", "codeAction go_test llm error: %v", err)
}
}
// Fallback stub
name := deriveGoFuncName(funcCode)
if name == "" {
name = "Function"
}
return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)
}
// deriveGoFuncName extracts function or method name from code.
func deriveGoFuncName(code string) string {
// look for line starting with func
line := firstLine(code)
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "func ") {
return ""
}
rest := strings.TrimSpace(strings.TrimPrefix(line, "func "))
// method receiver
if strings.HasPrefix(rest, "(") {
// find ")"
if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) {
rest = strings.TrimSpace(rest[i+1:])
}
}
// now rest should start with Name(
if i := strings.Index(rest, "("); i > 0 {
return strings.TrimSpace(rest[:i])
}
return ""
}
func exportName(name string) string {
if name == "" {
return name
}
r := []rune(name)
if r[0] >= 'a' && r[0] <= 'z' {
r[0] = r[0] - ('a' - 'A')
}
return string(r)
}
// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic.
package lsp
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) handleCompletion(req Request) {
var p CompletionParams
var docStr string
if err := json.Unmarshal(req.Params, &p); err == nil {
// Log trigger information for every completion request from client
tk, tch := extractTriggerInfo(p)
logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d",
tk, tch, p.TextDocument.URI, p.Position.Line, p.Position.Character)
above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position)
docStr = s.buildDocString(p, above, current, below, funcCtx)
if s.logContext {
s.logCompletionContext(p, above, current, below, funcCtx)
}
if s.llmClient != nil {
newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position)
extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position)
items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra)
if ok {
s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
return
}
}
}
items := s.fallbackCompletionItems(docStr)
s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
}
// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter
// if provided by the client; when absent it returns zeros.
func extractTriggerInfo(p CompletionParams) (kind int, ch string) {
if p.Context == nil {
return 0, ""
}
var ctx struct {
TriggerKind int `json:"triggerKind"`
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
_ = json.Unmarshal(raw, &ctx)
} else {
b, _ := json.Marshal(p.Context)
_ = json.Unmarshal(b, &ctx)
}
return ctx.TriggerKind, ctx.TriggerCharacter
}
// --- completion helpers ---
func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string {
return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s",
p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx))
}
func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) {
logging.Logf("lsp ", "completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q",
p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx))
}
func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
inlinePrompt := lineHasInlinePrompt(current)
if !inlinePrompt && !s.isTriggerEvent(p, current) {
logging.Logf("lsp ", "%scompletion skip=no-trigger line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return []CompletionItem{}, true
}
if s.shouldSuppressForChatTriggerEOL(current, p) {
return []CompletionItem{}, true
}
inParams := inParamList(current, p.Position.Character)
manualInvoke := parseManualInvoke(p.Context)
// Cache fast-path
key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText)
if cleaned, ok := s.completionCacheGet(key); ok && strings.TrimSpace(cleaned) != "" {
logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s",
p.TextDocument.URI, p.Position.Line, p.Position.Character,
logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true
}
if isBareDoubleOpen(current) || isBareDoubleOpen(below) {
logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return []CompletionItem{}, true
}
if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) {
logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return []CompletionItem{}, true
}
// Provider-native path
if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok {
return items, true
}
// Chat path
messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx)
// Counters and options
sentSize := 0
for _, m := range messages {
sentSize += len(m.Content)
}
s.incSentCounters(sentSize)
opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
if s.codingTemperature != nil {
opts = append(opts, llm.WithTemperature(*s.codingTemperature))
}
// Debounce and throttle before making the LLM call
s.waitForDebounce(ctx)
if !s.waitForThrottle(ctx) {
return nil, false
}
logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel())
text, err := s.llmClient.Chat(ctx, messages, opts...)
if err != nil {
logging.Logf("lsp ", "llm completion error: %v", err)
s.logLLMStats()
return nil, false
}
s.incRecvCounters(len(text))
s.logLLMStats()
cleaned := s.postProcessCompletion(strings.TrimSpace(text), current[:p.Position.Character], current)
if cleaned == "" {
return nil, false
}
s.completionCachePut(key, cleaned)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true
}
// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion.
func parseManualInvoke(ctx any) bool {
if ctx == nil {
return false
}
var c struct {
TriggerKind int `json:"triggerKind"`
}
if raw, ok := ctx.(json.RawMessage); ok {
_ = json.Unmarshal(raw, &c)
} else {
b, _ := json.Marshal(ctx)
_ = json.Unmarshal(b, &c)
}
return c.TriggerKind == 1
}
// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL.
func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool {
t := strings.TrimRight(current, " \t")
if s.chatSuffix == "" {
return false
}
if strings.HasSuffix(t, s.chatSuffix) {
if len(t) < len(s.chatSuffix)+1 {
return false
}
prev := string(t[len(t)-len(s.chatSuffix)-1])
for _, pf := range s.chatPrefixes {
if prev == pf {
logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line)
return true
}
}
}
return false
}
// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply.
func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool {
// Determine the effective cursor index within current line, clamped, and
// skip over trailing spaces/tabs to support cases like "type Matrix| ".
idx := p.Position.Character
if idx > len(current) {
idx = len(current)
}
allowNoPrefix := inlinePrompt
if idx > 0 {
ch := current[idx-1]
if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' {
allowNoPrefix = true
}
}
if allowNoPrefix {
return true
}
// Walk left over whitespace
j := idx
for j > 0 {
c := current[j-1]
if c == ' ' || c == '\t' {
j--
continue
}
break
}
start := computeWordStart(current, j)
min := 1
if manualInvoke && s.manualInvokeMinPrefix >= 0 {
min = s.manualInvokeMinPrefix
}
return j-start >= min
}
// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) {
cc, ok := s.llmClient.(llm.CodeCompleter)
if !ok {
return nil, false
}
before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position)
path := strings.TrimPrefix(p.TextDocument.URI, "file://")
// Build provider-native prompt from template
prompt := renderTemplate(s.promptNativeCompletion, map[string]string{
"path": path,
"before": before,
})
lang := ""
temp := 0.0
if s.codingTemperature != nil {
temp = *s.codingTemperature
}
prov := ""
if s.llmClient != nil {
prov = s.llmClient.Name()
}
logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path)
ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel2()
// Debounce and throttle prior to provider-native call
s.waitForDebounce(ctx2)
if !s.waitForThrottle(ctx2) {
return nil, false
}
suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp)
if err == nil && len(suggestions) > 0 {
cleaned := strings.TrimSpace(suggestions[0])
if cleaned != "" {
cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned)
if cleaned != "" {
cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned)
}
if cleaned != "" && hasDoubleOpenTrigger(current) {
indent := leadingIndent(current)
if indent != "" {
cleaned = applyIndent(indent, cleaned)
}
}
if strings.TrimSpace(cleaned) != "" {
key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText)
s.completionCachePut(key, cleaned)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true
}
}
} else if err != nil {
logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err)
}
return nil, false
}
// waitForDebounce sleeps until there has been no input activity for at least
// completionDebounce. If debounce is zero or ctx is done, it returns promptly.
func (s *Server) waitForDebounce(ctx context.Context) {
d := s.completionDebounce
if d <= 0 {
return
}
for {
s.mu.RLock()
last := s.lastInput
s.mu.RUnlock()
if last.IsZero() {
return
}
since := time.Since(last)
if since >= d {
return
}
rem := d - since
timer := time.NewTimer(rem)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
// loop and re-evaluate in case input occurred during sleep
}
}
}
// waitForThrottle enforces a minimum spacing between LLM calls. Returns false
// if the context is canceled while waiting.
func (s *Server) waitForThrottle(ctx context.Context) bool {
interval := s.throttleInterval
if interval <= 0 {
return true
}
var wait time.Duration
for {
s.mu.Lock()
next := s.lastLLMCall.Add(interval)
now := time.Now()
if now.Before(next) {
wait = next.Sub(now)
s.mu.Unlock()
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
// try again to set the next call time
continue
}
}
// we are allowed to proceed now; record this call as the latest
s.lastLLMCall = now
s.mu.Unlock()
return true
}
}
// buildCompletionMessages constructs the LLM messages for completion.
func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message {
// Vars for templates
vars := map[string]string{
"file": p.TextDocument.URI,
"function": funcCtx,
"above": above,
"current": current,
"below": below,
"char": fmt.Sprintf("%d", p.Position.Character),
}
sys := s.promptCompSysGeneral
userTpl := s.promptCompUserGeneral
if inParams {
sys = s.promptCompSysParams
userTpl = s.promptCompUserParams
}
if inlinePrompt && strings.TrimSpace(s.promptCompSysInline) != "" {
sys = s.promptCompSysInline
}
user := renderTemplate(userTpl, vars)
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
if hasExtra && strings.TrimSpace(extraText) != "" {
extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText})
if strings.TrimSpace(extra) == "" {
extra = extraText
}
messages = append(messages, llm.Message{Role: "user", Content: extra})
}
return messages
}
// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules.
func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string {
cleaned := stripCodeFences(text)
if cleaned != "" && strings.ContainsRune(cleaned, '`') {
if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" {
cleaned = inline
}
}
if cleaned != "" {
cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned)
}
if cleaned != "" {
cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned)
}
if cleaned != "" && hasDoubleOpenTrigger(currentLine) {
if indent := leadingIndent(currentLine); indent != "" {
cleaned = applyIndent(indent, cleaned)
}
}
return cleaned
}
// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go.
package lsp
import (
"context"
"encoding/json"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
)
// Package-level chat trigger vars for helpers without Server receiver.
// NewServer assigns these from configuration on startup.
var (
chatSuffixChar byte = '>'
chatPrefixSingles = []string{"?", "!", ":", ";"}
)
func (s *Server) handleDidOpen(req Request) {
var p DidOpenTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
s.markActivity()
}
}
func (s *Server) handleDidChange(req Request) {
var p DidChangeTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
if len(p.ContentChanges) > 0 {
s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text)
}
s.markActivity()
// Detect in-editor chat trigger lines and respond inline.
s.detectAndHandleChat(p.TextDocument.URI)
}
}
func (s *Server) handleDidClose(req Request) {
var p DidCloseTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.deleteDocument(p.TextDocument.URI)
s.markActivity()
}
}
// docBeforeAfter returns the full document text split at the given position.
// The returned strings are the text before the cursor (inclusive of anything
// left of the position) and the text after the cursor.
func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) {
d := s.getDocument(uri)
if d == nil {
return "", ""
}
// Clamp indices
line := pos.Line
if line < 0 {
line = 0
}
if line >= len(d.lines) {
line = len(d.lines) - 1
}
col := pos.Character
if col < 0 {
col = 0
}
if col > len(d.lines[line]) {
col = len(d.lines[line])
}
// Build before
var b strings.Builder
for i := 0; i < line; i++ {
b.WriteString(d.lines[i])
b.WriteByte('\n')
}
b.WriteString(d.lines[line][:col])
before := b.String()
// Build after
var a strings.Builder
a.WriteString(d.lines[line][col:])
for i := line + 1; i < len(d.lines); i++ {
a.WriteByte('\n')
a.WriteString(d.lines[i])
}
return before, a.String()
}
// --- in-editor chat (";C ...") ---
// detectAndHandleChat scans the current document for any line that starts with
// a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM
// reply below.
func (s *Server) detectAndHandleChat(uri string) {
if s.llmClient == nil {
return
}
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
return
}
for i, raw := range d.lines {
// Find last non-space character index
j := len(raw) - 1
for j >= 0 {
if raw[j] == ' ' || raw[j] == '\t' {
j--
continue
}
break
}
if j < 0 {
continue
}
// Check suffix/prefix according to configuration
if s.chatSuffix == "" {
continue
}
// Last non-space must equal suffix
if string(raw[j]) != s.chatSuffix {
continue
}
// Require at least one char before suffix and that char must be in chatPrefixes
if j < 1 {
continue
}
prev := string(raw[j-1])
isTrigger := false
for _, pfx := range s.chatPrefixes {
if prev == pfx {
isTrigger = true
break
}
}
if !isTrigger {
continue
}
// Avoid double-answering: if the next non-empty line starts with '>' we skip.
k := i + 1
for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" {
k++
}
if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") {
continue
}
// Derive prompt by removing only the trailing '>'
removeCount := len(s.chatSuffix)
base := raw[:j+1-removeCount]
prompt := strings.TrimSpace(base)
if prompt == "" {
continue
}
lineIdx := i
lastIdx := j
go func(prompt string, remove int) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
sys := s.promptChatSystem
// Build short conversation history from the document above this line
history := s.buildChatHistory(uri, lineIdx, prompt)
msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...)
opts := s.llmRequestOpts()
logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel())
text, err := s.llmClient.Chat(ctx, msgs, opts...)
if err != nil {
logging.Logf("lsp ", "chat llm error: %v", err)
return
}
out := strings.TrimSpace(stripCodeFences(text))
if out == "" {
return
}
s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)
}(prompt, removeCount)
// Only handle one per change tick to avoid flooding
break
}
}
// applyChatEdits removes the triggering punctuation at end of the line and
// inserts two newlines followed by a new line with the response prefixed.
func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) {
d := s.getDocument(uri)
if d == nil {
return
}
// 1) Delete the trailing punctuation (1 or 2 chars)
delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount}
delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1}
// 2) Insert two newlines and the response at end-of-line, then one extra blank line
insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])}
resp := strings.TrimRight(response, "\n") + "\n"
insert := "\n\n" + resp + "\n"
edits := []TextEdit{
{Range: Range{Start: delStart, End: delEnd}, NewText: ""},
{Range: Range{Start: insPos, End: insPos}, NewText: insert},
}
we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}}
s.clientApplyEdit("Hexai: insert chat response", we)
}
// buildChatHistory walks upwards from the current line to collect the most recent
// Q/A pairs in the in-editor transcript. Returns messages ending with current prompt.
func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message {
d := s.getDocument(uri)
if d == nil {
return []llm.Message{{Role: "user", Content: currentPrompt}}
}
type pair struct{ q, a string }
pairs := []pair{}
i := lineIdx - 1
for i >= 0 && len(pairs) < 3 {
for i >= 0 && strings.TrimSpace(d.lines[i]) == "" {
i--
}
if i < 0 {
break
}
if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") {
break
}
var replyLines []string
for i >= 0 {
line := strings.TrimSpace(d.lines[i])
if strings.HasPrefix(line, ">") {
replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...)
i--
continue
}
break
}
for i >= 0 && strings.TrimSpace(d.lines[i]) == "" {
i--
}
if i < 0 {
break
}
q := strings.TrimSpace(d.lines[i])
q = stripTrailingTrigger(q)
pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...)
i--
}
msgs := make([]llm.Message, 0, len(pairs)*2+1)
for _, p := range pairs {
if strings.TrimSpace(p.q) != "" {
msgs = append(msgs, llm.Message{Role: "user", Content: p.q})
}
if strings.TrimSpace(p.a) != "" {
msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a})
}
}
msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt})
return msgs
}
// stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present.
func stripTrailingTrigger(sx string) string {
s := strings.TrimRight(sx, " \t")
if len(s) == 0 {
return sx
}
// Configurable suffix removal when preceded by configured prefixes
if len(s) >= 2 && s[len(s)-1] == chatSuffixChar {
prev := string(s[len(s)-2])
for _, pf := range chatPrefixSingles {
if prev == pf {
return strings.TrimRight(s[:len(s)-1], " \t")
}
}
}
// Legacy: remove one trailing punctuation (?, !, :) to build history nicely
last := s[len(s)-1]
switch last {
case '?', '!', ':':
return strings.TrimRight(s[:len(s)-1], " \t")
default:
return sx
}
}
// clientApplyEdit sends a workspace/applyEdit request to the client.
func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) {
params := ApplyWorkspaceEditParams{Label: label, Edit: edit}
id := s.nextReqID()
req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"}
b, _ := json.Marshal(params)
req.Params = b
s.writeMessage(req)
}
// nextReqID returns a unique json.RawMessage id for server-initiated requests.
func (s *Server) nextReqID() json.RawMessage {
s.mu.Lock()
s.nextID++
idNum := s.nextID
s.mu.Unlock()
b, _ := json.Marshal(idNum)
return b
}
// clientShowDocument asks the client to open/focus a document and select a range.
func (s *Server) clientShowDocument(uri string, sel *Range) {
var params struct {
URI string `json:"uri"`
External bool `json:"external,omitempty"`
TakeFocus bool `json:"takeFocus,omitempty"`
Selection *Range `json:"selection,omitempty"`
}
params.URI = uri
params.TakeFocus = true
params.Selection = sel
id := s.nextReqID()
req := Request{JSONRPC: "2.0", ID: id, Method: "window/showDocument"}
b, _ := json.Marshal(params)
req.Params = b
s.writeMessage(req)
}
// deferShowDocument schedules a showDocument after a short delay to allow the client
// time to apply any pending edits (e.g., create the file before focusing it).
func (s *Server) deferShowDocument(uri string, sel Range) {
go func() {
time.Sleep(120 * time.Millisecond)
s.clientShowDocument(uri, &sel)
}()
}
// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test).
package lsp
import (
"encoding/json"
)
func (s *Server) handleExecuteCommand(req Request) {
var p ExecuteCommandParams
if err := json.Unmarshal(req.Params, &p); err != nil {
s.reply(req.ID, nil, nil)
return
}
switch p.Command {
case "hexai.showDocument":
if len(p.Arguments) >= 2 {
uri, _ := p.Arguments[0].(string)
var r Range
// Convert second arg to Range via re-marshal to be robust across clients
if b, err := json.Marshal(p.Arguments[1]); err == nil {
_ = json.Unmarshal(b, &r)
}
if uri != "" {
s.clientShowDocument(uri, &r)
}
}
s.reply(req.ID, nil, nil)
return
default:
// Unknown command; no-op
s.reply(req.ID, nil, nil)
return
}
}
// Summary: Initialization and lifecycle handlers split from handlers.go.
package lsp
import (
"os"
"codeberg.org/snonux/hexai/internal"
"codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) handleInitialize(req Request) {
version := internal.Version
if s.llmClient != nil {
version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]"
}
res := InitializeResult{
Capabilities: ServerCapabilities{
TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
CompletionProvider: &CompletionOptions{
ResolveProvider: false,
TriggerCharacters: s.triggerChars,
},
CodeActionProvider: CodeActionOptions{ResolveProvider: true},
},
ServerInfo: &ServerInfo{Name: "hexai", Version: version},
}
s.reply(req.ID, res, nil)
}
func (s *Server) handleInitialized() {
logging.Logf("lsp ", "client initialized")
}
func (s *Server) handleShutdown(req Request) {
s.reply(req.ID, nil, nil)
}
func (s *Server) handleExit() {
s.exited = true
os.Exit(0)
}
// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters).
package lsp
import (
"strings"
"time"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/textutil"
)
// Configurable inline trigger characters (default to '>') used by free helpers below.
// NewServer assigns these based on ServerOptions.
var (
inlineOpenChar byte = '>'
inlineCloseChar byte = '>'
)
// llmRequestOpts builds request options from server settings.
func (s *Server) llmRequestOpts() []llm.RequestOption {
opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
if s.codingTemperature != nil {
opts = append(opts, llm.WithTemperature(*s.codingTemperature))
}
return opts
}
// small helpers for LLM traffic stats
func (s *Server) incSentCounters(n int) {
s.mu.Lock()
s.llmReqTotal++
s.llmSentBytesTotal += int64(n)
s.mu.Unlock()
}
func (s *Server) incRecvCounters(n int) {
s.mu.Lock()
s.llmRespTotal++
s.llmRespBytesTotal += int64(n)
s.mu.Unlock()
}
func (s *Server) logLLMStats() {
s.mu.RLock()
avgSent := int64(0)
if s.llmReqTotal > 0 {
avgSent = s.llmSentBytesTotal / s.llmReqTotal
}
avgRecv := int64(0)
if s.llmRespTotal > 0 {
avgRecv = s.llmRespBytesTotal / s.llmRespTotal
}
reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal
s.mu.RUnlock()
mins := time.Since(s.startTime).Minutes()
if mins <= 0 {
mins = 0.001
}
rpm := float64(reqs) / mins
sentPerMin := float64(sentTot) / mins
recvPerMin := float64(recvTot) / mins
logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin)
}
// Completion prompt builders and filters
func inParamList(current string, cursor int) bool {
if !strings.Contains(current, "func ") {
return false
}
open := strings.Index(current, "(")
close := strings.Index(current, ")")
return open >= 0 && cursor > open && (close == -1 || cursor <= close)
}
// renderTemplate performs simple {{var}} replacement in a template string.
func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) }
func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) {
if inParams {
open := strings.Index(current, "(")
close := strings.Index(current, ")")
if open >= 0 {
left := open + 1
right := len(current)
if close >= 0 && close >= left {
right = close
}
if p.Position.Character < right {
right = p.Position.Character
}
te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned}
var filter string
if left >= 0 && right >= left && right <= len(current) {
filter = strings.TrimLeft(current[left:right], " \t")
}
return te, filter
}
}
startChar := computeWordStart(current, p.Position.Character)
te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned}
filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t")
return te, filter
}
func computeWordStart(current string, at int) int {
if at > len(current) {
at = len(current)
}
for at > 0 {
ch := current[at-1]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' {
at--
continue
}
break
}
return at
}
func isIdentChar(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
}
// Inline prompt utilities
func lineHasInlinePrompt(line string) bool {
if _, _, _, ok := findStrictInlineTag(line); ok {
return true
}
return hasDoubleOpenTrigger(line)
}
func leadingIndent(line string) string {
i := 0
for i < len(line) {
if line[i] == ' ' || line[i] == '\t' {
i++
continue
}
break
}
if i == 0 {
return ""
}
return line[:i]
}
func applyIndent(indent, suggestion string) string {
if indent == "" || suggestion == "" {
return suggestion
}
lines := splitLines(suggestion)
for i, ln := range lines {
if strings.TrimSpace(ln) == "" {
continue
}
if strings.HasPrefix(ln, indent) {
continue
}
lines[i] = indent + ln
}
return strings.Join(lines, "\n")
}
// --- Inline marker parsing and general string utilities ---
// findStrictInlineTag finds >text> (configurable), with no space after the first
// opening marker and no space immediately before the closing marker. Returns the
// text between markers, the start index, the end index just after closing, and ok.
func findStrictInlineTag(line string) (string, int, int, bool) {
pos := 0
for pos < len(line) {
// find opening marker
j := strings.IndexByte(line[pos:], inlineOpenChar)
if j < 0 {
return "", 0, 0, false
}
j += pos
// ensure single open (not double) and non-space after
if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' {
pos = j + 1
continue
}
// find closing marker
k := strings.IndexByte(line[j+1:], inlineCloseChar)
if k < 0 {
return "", 0, 0, false
}
closeIdx := j + 1 + k
if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
inner := strings.TrimSpace(line[j+1 : closeIdx])
if inner == "" {
pos = closeIdx + 1
continue
}
end := closeIdx + 1
return inner, j, end, true
}
return "", 0, 0, false
}
// isBareDoubleSemicolon reports whether the line contains a standalone
// double-semicolon marker with no inline content (";;" possibly with only
// whitespace after it). It explicitly excludes the valid form ";;text;".
func isBareDoubleOpen(line string) bool {
t := strings.TrimSpace(line)
// check for double-open pattern
dbl := string([]byte{inlineOpenChar, inlineOpenChar})
if !strings.Contains(t, dbl) {
return false
}
if hasDoubleOpenTrigger(t) {
return false
}
if strings.HasPrefix(t, dbl) {
rest := strings.TrimSpace(t[len(dbl):])
if rest == "" || rest == ";" {
return true
}
}
return false
}
// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion.
func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string {
s2 := strings.TrimLeft(suggestion, " \t")
// Prefer := if present at end of prefix
if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) {
tail := prefixBeforeCursor[idx+2:]
if strings.TrimSpace(tail) == "" {
start := idx - 1
for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
start--
}
start++
seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t")
if strings.HasPrefix(s2, seg) {
return strings.TrimLeft(s2[len(seg):], " \t")
}
}
}
// Fallback to plain '=' if present
if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 {
if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') { // not :=
tail := prefixBeforeCursor[idx+1:]
if strings.TrimSpace(tail) == "" {
start := idx - 1
for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
start--
}
start++
seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t")
if strings.HasPrefix(s2, seg) {
return strings.TrimLeft(s2[len(seg):], " \t")
}
}
}
}
return suggestion
}
// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated.
func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string {
if suggestion == "" {
return suggestion
}
s := strings.TrimLeft(suggestion, " \t")
p := strings.TrimRight(prefixBeforeCursor, " \t")
if p != "" && strings.HasPrefix(s, p) {
return strings.TrimLeft(s[len(p):], " \t")
}
for k := len(p) - 1; k > 0; k-- {
if !isIdentBoundary(p[k-1]) {
continue
}
suf := strings.TrimLeft(p[k:], " \t")
if suf == "" {
continue
}
if strings.HasPrefix(s, suf) {
return strings.TrimLeft(s[len(suf):], " \t")
}
}
return suggestion
}
func isIdentBoundary(ch byte) bool {
return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_')
}
// stripCodeFences removes surrounding Markdown code fences from a model response.
func stripCodeFences(s string) string { return textutil.StripCodeFences(s) }
// stripInlineCodeSpan returns the contents of the first inline backtick code span if present.
func stripInlineCodeSpan(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return t
}
i := strings.IndexByte(t, '`')
if i < 0 {
return t
}
jrel := strings.IndexByte(t[i+1:], '`')
if jrel < 0 {
return t
}
j := i + 1 + jrel
return t[i+1 : j]
}
// labelForCompletion picks a short, readable label for the completion list.
func labelForCompletion(cleaned, filter string) string {
label := trimLen(firstLine(cleaned))
if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) {
return filter
}
return label
}
// extractRangeText returns the exact text within the given document range.
func extractRangeText(d *document, r Range) string {
if r.Start.Line == r.End.Line {
line := d.lines[r.Start.Line]
if r.Start.Character < 0 {
r.Start.Character = 0
}
if r.End.Character > len(line) {
r.End.Character = len(line)
}
if r.Start.Character > r.End.Character {
return ""
}
return line[r.Start.Character:r.End.Character]
}
var b strings.Builder
// first line
first := d.lines[r.Start.Line]
if r.Start.Character < 0 {
r.Start.Character = 0
}
if r.Start.Character > len(first) {
r.Start.Character = len(first)
}
b.WriteString(first[r.Start.Character:])
b.WriteString("\n")
// middle lines
for i := r.Start.Line + 1; i < r.End.Line; i++ {
b.WriteString(d.lines[i])
if i+1 <= r.End.Line {
b.WriteString("\n")
}
}
// last line
last := d.lines[r.End.Line]
if r.End.Character < 0 {
r.End.Character = 0
}
if r.End.Character > len(last) {
r.End.Character = len(last)
}
b.WriteString(last[:r.End.Character])
return b.String()
}
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
return nil
}
var edits []TextEdit
for i, line := range d.lines {
edits = append(edits, promptRemovalEditsForLine(line, i)...)
}
return edits
}
func promptRemovalEditsForLine(line string, lineNum int) []TextEdit {
if hasDoubleOpenTrigger(line) {
return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}}
}
return collectSemicolonMarkers(line, lineNum)
}
func hasDoubleOpenTrigger(line string) bool {
pos := 0
for pos < len(line) {
// look for double-open sequence
dbl := string([]byte{inlineOpenChar, inlineOpenChar})
j := strings.Index(line[pos:], dbl)
if j < 0 {
return false
}
j += pos
contentStart := j + len(dbl)
if contentStart >= len(line) {
return false
}
first := line[contentStart]
if first == ' ' || first == inlineOpenChar {
pos = contentStart + 1
continue
}
// find closing
k := strings.IndexByte(line[contentStart+1:], inlineCloseChar)
if k < 0 {
return false
}
closeIdx := contentStart + 1 + k
if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
return true
}
return false
}
func collectSemicolonMarkers(line string, lineNum int) []TextEdit {
var edits []TextEdit
startSemi := 0
for startSemi < len(line) {
j := strings.IndexByte(line[startSemi:], inlineOpenChar)
if j < 0 {
break
}
j += startSemi
k := strings.IndexByte(line[j+1:], inlineCloseChar)
if k < 0 {
break
}
if j+1 >= len(line) || line[j+1] == ' ' {
startSemi = j + 1
continue
}
if line[j+1] == inlineOpenChar { // skip double-open start
startSemi = j + 2
continue
}
closeIdx := j + 1 + k
if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
startSemi = closeIdx + 1
continue
}
if closeIdx-(j+1) < 1 {
startSemi = closeIdx + 1
continue
}
endChar := closeIdx + 1
if endChar < len(line) && line[endChar] == ' ' {
endChar++
}
edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""})
startSemi = endChar
}
return edits
}
// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats.
package lsp
import (
"bufio"
"encoding/json"
"io"
"log"
"strings"
"sync"
"time"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
)
// Server implements a minimal LSP over stdio.
type Server struct {
in *bufio.Reader
out io.Writer
logger *log.Logger
exited bool
mu sync.RWMutex
docs map[string]*document
logContext bool
llmClient llm.Client
lastInput time.Time
maxTokens int
contextMode string
windowLines int
maxContextTokens int
triggerChars []string
// If set, used as the LSP coding temperature for all LLM calls
codingTemperature *float64
// LLM request stats
llmReqTotal int64
llmSentBytesTotal int64
llmRespTotal int64
llmRespBytesTotal int64
startTime time.Time
// Small LRU cache for recent code completion outputs (keyed by context)
compCache map[string]string
compCacheOrder []string // most-recent at end; cap ~10
// Outgoing JSON-RPC id counter for server-initiated requests
nextID int64
// Minimum identifier chars required for manual invoke to bypass prefix checks
manualInvokeMinPrefix int
// Debounce and throttle settings
completionDebounce time.Duration
throttleInterval time.Duration
lastLLMCall time.Time
// Dispatch table for JSON-RPC methods → handler functions
handlers map[string]func(Request)
// Configurable trigger characters
inlineOpen string
inlineClose string
chatSuffix string
chatPrefixes []string
// Prompt templates
// Completion
promptCompSysGeneral string
promptCompSysParams string
promptCompSysInline string
promptCompUserGeneral string
promptCompUserParams string
promptCompExtraHeader string
// Provider-native code completion
promptNativeCompletion string
// In-editor chat
promptChatSystem string
// Code actions
promptRewriteSystem string
promptDiagnosticsSystem string
promptDocumentSystem string
promptRewriteUser string
promptDiagnosticsUser string
promptDocumentUser string
promptGoTestSystem string
promptGoTestUser string
}
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
type ServerOptions struct {
LogContext bool
MaxTokens int
ContextMode string
WindowLines int
MaxContextTokens int
Client llm.Client
TriggerCharacters []string
CodingTemperature *float64
ManualInvokeMinPrefix int
CompletionDebounceMs int
CompletionThrottleMs int
// Inline/chat triggers
InlineOpen string
InlineClose string
ChatSuffix string
ChatPrefixes []string
// Prompt templates
PromptCompSysGeneral string
PromptCompSysParams string
PromptCompSysInline string
PromptCompUserGeneral string
PromptCompUserParams string
PromptCompExtraHeader string
PromptNativeCompletion string
PromptChatSystem string
PromptRewriteSystem string
PromptDiagnosticsSystem string
PromptDocumentSystem string
PromptRewriteUser string
PromptDiagnosticsUser string
PromptDocumentUser string
PromptGoTestSystem string
PromptGoTestUser string
}
func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server {
s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext}
maxTokens := opts.MaxTokens
if maxTokens <= 0 {
maxTokens = 500
}
s.maxTokens = maxTokens
contextMode := opts.ContextMode
if contextMode == "" {
contextMode = "file-on-new-func"
}
windowLines := opts.WindowLines
if windowLines <= 0 {
windowLines = 120
}
maxContextTokens := opts.MaxContextTokens
if maxContextTokens <= 0 {
maxContextTokens = 2000
}
s.contextMode = contextMode
s.windowLines = windowLines
s.maxContextTokens = maxContextTokens
s.startTime = time.Now()
s.llmClient = opts.Client
if len(opts.TriggerCharacters) == 0 {
// Defaults (no space to avoid auto-trigger after whitespace)
s.triggerChars = []string{".", ":", "/", "_", ")", "{"}
} else {
s.triggerChars = append([]string{}, opts.TriggerCharacters...)
}
s.codingTemperature = opts.CodingTemperature
s.compCache = make(map[string]string)
s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix
if opts.CompletionDebounceMs > 0 {
s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond
}
if opts.CompletionThrottleMs > 0 {
s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond
}
// Trigger character config (with sane defaults if missing)
if strings.TrimSpace(opts.InlineOpen) == "" {
s.inlineOpen = ">"
} else {
s.inlineOpen = opts.InlineOpen
}
if strings.TrimSpace(opts.InlineClose) == "" {
s.inlineClose = ">"
} else {
s.inlineClose = opts.InlineClose
}
if strings.TrimSpace(opts.ChatSuffix) == "" {
s.chatSuffix = ">"
} else {
s.chatSuffix = opts.ChatSuffix
}
if len(opts.ChatPrefixes) == 0 {
s.chatPrefixes = []string{"?", "!", ":", ";"}
} else {
s.chatPrefixes = append([]string{}, opts.ChatPrefixes...)
}
// Prompts
s.promptCompSysGeneral = opts.PromptCompSysGeneral
s.promptCompSysParams = opts.PromptCompSysParams
s.promptCompSysInline = opts.PromptCompSysInline
s.promptCompUserGeneral = opts.PromptCompUserGeneral
s.promptCompUserParams = opts.PromptCompUserParams
s.promptCompExtraHeader = opts.PromptCompExtraHeader
s.promptNativeCompletion = opts.PromptNativeCompletion
s.promptChatSystem = opts.PromptChatSystem
s.promptRewriteSystem = opts.PromptRewriteSystem
s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem
s.promptDocumentSystem = opts.PromptDocumentSystem
s.promptRewriteUser = opts.PromptRewriteUser
s.promptDiagnosticsUser = opts.PromptDiagnosticsUser
s.promptDocumentUser = opts.PromptDocumentUser
s.promptGoTestSystem = opts.PromptGoTestSystem
s.promptGoTestUser = opts.PromptGoTestUser
// Assign package-level inline trigger chars for free helper functions
if s.inlineOpen != "" {
inlineOpenChar = s.inlineOpen[0]
}
if s.inlineClose != "" {
inlineCloseChar = s.inlineClose[0]
}
if s.chatSuffix != "" {
chatSuffixChar = s.chatSuffix[0]
}
if len(s.chatPrefixes) > 0 {
chatPrefixSingles = append([]string{}, s.chatPrefixes...)
}
// Initialize dispatch table
s.handlers = map[string]func(Request){
"initialize": s.handleInitialize,
"initialized": func(_ Request) { s.handleInitialized() },
"shutdown": s.handleShutdown,
"exit": func(_ Request) { s.handleExit() },
"textDocument/didOpen": s.handleDidOpen,
"textDocument/didChange": s.handleDidChange,
"textDocument/didClose": s.handleDidClose,
"textDocument/completion": s.handleCompletion,
"textDocument/codeAction": s.handleCodeAction,
"codeAction/resolve": s.handleCodeActionResolve,
"workspace/executeCommand": s.handleExecuteCommand,
}
return s
}
func (s *Server) Run() error {
for {
body, err := s.readMessage()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
var req Request
if err := json.Unmarshal(body, &req); err != nil {
logging.Logf("lsp ", "invalid JSON: %v", err)
continue
}
if req.Method == "" {
// A response from client; ignore
continue
}
go s.handle(req)
if s.exited {
return nil
}
}
}
// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing.
package lsp
import (
"encoding/json"
"fmt"
"io"
"net/textproto"
"strconv"
"strings"
"codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) readMessage() ([]byte, error) {
tp := textproto.NewReader(s.in)
var contentLength int
for {
line, err := tp.ReadLine()
if err != nil {
return nil, err
}
if line == "" { // end of headers
break
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(strings.ToLower(parts[0]))
val := strings.TrimSpace(parts[1])
switch key {
case "content-length":
n, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("invalid Content-Length: %v", err)
}
contentLength = n
}
}
if contentLength <= 0 {
return nil, fmt.Errorf("missing or invalid Content-Length")
}
buf := make([]byte, contentLength)
if _, err := io.ReadFull(s.in, buf); err != nil {
return nil, err
}
return buf, nil
}
func (s *Server) writeMessage(v any) {
data, err := json.Marshal(v)
if err != nil {
logging.Logf("lsp ", "marshal error: %v", err)
return
}
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
if _, err := io.WriteString(s.out, header); err != nil {
logging.Logf("lsp ", "write header error: %v", err)
return
}
if _, err := s.out.Write(data); err != nil {
logging.Logf("lsp ", "write body error: %v", err)
return
}
}
package testutil
// MultilineDocBlock returns a realistic multi-line documentation block.
func MultilineDocBlock() string {
return "// add adds two numbers\n// returns their sum"
}
// MultilineChatReply returns a multi-line assistant reply for chat tests.
func MultilineChatReply() string {
return "Hello, world!\nThis is a multi-line reply."
}
// MultilineFunctionSuggestion returns a more realistic multi-line function body suggestion.
func MultilineFunctionSuggestion() string {
return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}"
}
// MarkdownCodeFence returns a fenced markdown snippet used in post-processing tests.
func MarkdownCodeFence() string {
return "```go\nname := value\n```"
}
// MalformedJSON returns a deliberately malformed JSON string.
func MalformedJSON() string {
return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]"
}
package textutil
import "strings"
// RenderTemplate performs simple {{var}} replacement in a template string.
func RenderTemplate(t string, vars map[string]string) string {
if t == "" || len(vars) == 0 {
return t
}
out := t
for k, v := range vars {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}
// StripCodeFences removes surrounding Markdown triple-backtick fences.
func StripCodeFences(s string) string {
t := strings.TrimSpace(s)
if t == "" {
return t
}
lines := strings.Split(t, "\n")
start := 0
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
start++
}
end := len(lines) - 1
for end >= 0 && strings.TrimSpace(lines[end]) == "" {
end--
}
if start >= len(lines) || end < 0 || start > end {
return t
}
first := strings.TrimSpace(lines[start])
last := strings.TrimSpace(lines[end])
if strings.HasPrefix(first, "```") && last == "```" && end > start {
inner := strings.Join(lines[start+1:end], "\n")
return inner
}
return t
}
// InstructionFromSelection extracts the first inline instruction and returns
// (instruction, cleanedSelection). It detects markers on the earliest position
// per line in precedence: strict ;text;, /* */, <!-- -->, //, #, --.
func InstructionFromSelection(sel string) (string, string) {
lines := strings.Split(sel, "\n")
for idx, line := range lines {
if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" {
lines[idx] = cleaned
return instr, strings.Join(lines, "\n")
}
}
return "", sel
}
// FindFirstInstructionInLine returns (instruction, cleaned, ok) for a single line.
func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) {
type cand struct{ start, end int; text string }
cands := []cand{}
if t, l, r, ok := FindStrictInlineTag(line); ok {
cands = append(cands, cand{start: l, end: r, text: t})
}
if i := strings.Index(line, "/*"); i >= 0 {
if j := strings.Index(line[i+2:], "*/"); j >= 0 {
start := i
end := i + 2 + j + 2
text := strings.TrimSpace(line[i+2 : i+2+j])
cands = append(cands, cand{start: start, end: end, text: text})
}
}
if i := strings.Index(line, "<!--"); i >= 0 {
if j := strings.Index(line[i+4:], "-->"); j >= 0 {
start := i
end := i + 4 + j + 3
text := strings.TrimSpace(line[i+4 : i+4+j])
cands = append(cands, cand{start: start, end: end, text: text})
}
}
if i := strings.Index(line, "//"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
}
if i := strings.Index(line, "#"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])})
}
if i := strings.Index(line, "--"); i >= 0 {
cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
}
if len(cands) == 0 { return "", line, false }
best := cands[0]
for _, c := range cands[1:] {
if c.start >= 0 && (best.start < 0 || c.start < best.start) { best = c }
}
cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t")
return best.text, cleaned, true
}
// FindStrictInlineTag finds ;text; with no spaces after/before semicolons.
func FindStrictInlineTag(line string) (text string, left, right int, ok bool) {
for i := 0; i < len(line); i++ {
if line[i] != ';' { continue }
if i+1 < len(line) && line[i+1] == ' ' { continue }
for j := i + 1; j < len(line); j++ {
if line[j] == ';' {
if j-1 >= 0 && line[j-1] == ' ' { continue }
inner := strings.TrimSpace(line[i+1 : j])
if inner != "" { return inner, i, j + 1, true }
}
}
}
return "", -1, -1, false
}
package tmux
import (
"os"
"os/exec"
"strconv"
"strings"
)
// Available reports whether tmux is available and we appear to be in a tmux session.
func Available() bool { return HasBinary() && InSession() }
// HasBinary reports whether the tmux binary is on PATH.
var lookPath = exec.LookPath
var command = exec.Command
func HasBinary() bool { _, err := lookPath("tmux"); return err == nil }
// InSession reports whether we seem to be running inside a tmux session.
func InSession() bool { return strings.TrimSpace(os.Getenv("TMUX")) != "" }
// SplitOpts controls how a new pane is created for running a command.
type SplitOpts struct {
Target string // optional pane target, e.g. ":."
Vertical bool // true => split vertically (-v); false => horizontally (-h)
Percent int // 1..100; 0 means use tmux default
}
// SplitRun splits the current tmux window and runs argv in the new pane.
// It returns once tmux has launched the child process.
func SplitRun(opts SplitOpts, argv []string) error {
if len(argv) == 0 {
return nil
}
args := []string{"split-window"}
if opts.Vertical {
args = append(args, "-v")
} else {
args = append(args, "-h")
}
if opts.Percent > 0 && opts.Percent <= 100 {
args = append(args, "-p", strconv.Itoa(opts.Percent))
}
if strings.TrimSpace(opts.Target) != "" {
args = append(args, "-t", opts.Target)
}
// tmux takes a single command string. Use a conservative shell join.
cmdStr := shellJoin(argv)
args = append(args, cmdStr)
c := command("tmux", args...)
return c.Run()
}
// shellJoin quotes argv elements for safe use in a single shell command string.
// It avoids interpretation by wrapping in single quotes and escaping embedded single quotes.
func shellJoin(argv []string) string {
out := make([]string, 0, len(argv))
for _, a := range argv {
if a == "" {
out = append(out, "''")
continue
}
if isSafeBare(a) {
out = append(out, a)
continue
}
// single-quote wrapping with escaped single quotes
// ' => '\'' (close, escaped quote, reopen)
esc := strings.ReplaceAll(a, "'", "'\\''")
out = append(out, "'"+esc+"'")
}
return strings.Join(out, " ")
}
// isSafeBare returns true if a contains only safe characters for bare words.
func isSafeBare(s string) bool {
for i := 0; i < len(s); i++ {
b := s[i]
if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' {
continue
}
return false
}
return true
}