diff options
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 11263 |
1 files changed, 11263 insertions, 0 deletions
diff --git a/docs/coverage.html b/docs/coverage.html new file mode 100644 index 0000000..70dbf00 --- /dev/null +++ b/docs/coverage.html @@ -0,0 +1,11263 @@ + +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>hexai-lsp: Go Coverage Report</title> + <style> + body { + background: black; + color: rgb(80, 80, 80); + } + body, pre, #legend span { + font-family: Menlo, monospace; + font-weight: bold; + } + #topbar { + background: black; + position: fixed; + top: 0; left: 0; right: 0; + height: 42px; + border-bottom: 1px solid rgb(80, 80, 80); + } + #content { + margin-top: 50px; + } + #nav, #legend { + float: left; + margin-left: 10px; + } + #legend { + margin-top: 12px; + } + #nav { + margin-top: 10px; + } + #legend span { + margin: 0 5px; + } + .cov0 { color: rgb(192, 0, 0) } +.cov1 { color: rgb(128, 128, 128) } +.cov2 { color: rgb(116, 140, 131) } +.cov3 { color: rgb(104, 152, 134) } +.cov4 { color: rgb(92, 164, 137) } +.cov5 { color: rgb(80, 176, 140) } +.cov6 { color: rgb(68, 188, 143) } +.cov7 { color: rgb(56, 200, 146) } +.cov8 { color: rgb(44, 212, 149) } +.cov9 { color: rgb(32, 224, 152) } +.cov10 { color: rgb(20, 236, 155) } + + </style> + </head> + <body> + <div id="topbar"> + <div id="nav"> + <select id="files"> + + <option value="file0">codeberg.org/snonux/hexai/cmd/hexai-lsp/main.go (73.3%)</option> + + <option value="file1">codeberg.org/snonux/hexai/cmd/hexai-tmux-action/main.go (0.0%)</option> + + <option value="file2">codeberg.org/snonux/hexai/cmd/hexai-tmux-edit/main.go (0.0%)</option> + + <option value="file3">codeberg.org/snonux/hexai/cmd/hexai/main.go (61.9%)</option> + + <option value="file4">codeberg.org/snonux/hexai/internal/appconfig/config.go (82.4%)</option> + + <option value="file5">codeberg.org/snonux/hexai/internal/editor/editor.go (58.3%)</option> + + <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/cmdentry.go (84.5%)</option> + + <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/parse.go (92.6%)</option> + + <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (92.3%)</option> + + <option value="file9">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (71.1%)</option> + + <option value="file10">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option> + + <option value="file11">codeberg.org/snonux/hexai/internal/hexaiaction/tui_custom.go (83.8%)</option> + + <option value="file12">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (100.0%)</option> + + <option value="file13">codeberg.org/snonux/hexai/internal/hexaicli/run.go (71.4%)</option> + + <option value="file14">codeberg.org/snonux/hexai/internal/hexailsp/run.go (88.6%)</option> + + <option value="file15">codeberg.org/snonux/hexai/internal/ignore/checker.go (100.0%)</option> + + <option value="file16">codeberg.org/snonux/hexai/internal/llm/anthropic.go (80.4%)</option> + + <option value="file17">codeberg.org/snonux/hexai/internal/llm/ollama.go (88.7%)</option> + + <option value="file18">codeberg.org/snonux/hexai/internal/llm/openai.go (86.4%)</option> + + <option value="file19">codeberg.org/snonux/hexai/internal/llm/openrouter.go (75.8%)</option> + + <option value="file20">codeberg.org/snonux/hexai/internal/llm/provider.go (72.1%)</option> + + <option value="file21">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option> + + <option value="file22">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option> + + <option value="file23">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option> + + <option value="file24">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> + + <option value="file25">codeberg.org/snonux/hexai/internal/lsp/chat_commands.go (83.3%)</option> + + <option value="file26">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option> + + <option value="file27">codeberg.org/snonux/hexai/internal/lsp/document.go (91.5%)</option> + + <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers.go (89.8%)</option> + + <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (81.1%)</option> + + <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (75.4%)</option> + + <option value="file31">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (77.8%)</option> + + <option value="file32">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> + + <option value="file33">codeberg.org/snonux/hexai/internal/lsp/handlers_ignore.go (86.7%)</option> + + <option value="file34">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option> + + <option value="file35">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (85.3%)</option> + + <option value="file36">codeberg.org/snonux/hexai/internal/lsp/server.go (81.0%)</option> + + <option value="file37">codeberg.org/snonux/hexai/internal/lsp/transport.go (73.0%)</option> + + <option value="file38">codeberg.org/snonux/hexai/internal/runtimeconfig/store.go (88.1%)</option> + + <option value="file39">codeberg.org/snonux/hexai/internal/stats/lock_posix.go (83.3%)</option> + + <option value="file40">codeberg.org/snonux/hexai/internal/stats/stats.go (76.0%)</option> + + <option value="file41">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option> + + <option value="file42">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option> + + <option value="file43">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option> + + <option value="file44">codeberg.org/snonux/hexai/internal/tmux/status.go (76.7%)</option> + + <option value="file45">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> + + <option value="file46">codeberg.org/snonux/hexai/internal/tmuxedit/agent.go (82.1%)</option> + + <option value="file47">codeberg.org/snonux/hexai/internal/tmuxedit/agentutil.go (94.2%)</option> + + <option value="file48">codeberg.org/snonux/hexai/internal/tmuxedit/capture.go (100.0%)</option> + + <option value="file49">codeberg.org/snonux/hexai/internal/tmuxedit/claude_agent.go (86.2%)</option> + + <option value="file50">codeberg.org/snonux/hexai/internal/tmuxedit/config_agent.go (97.5%)</option> + + <option value="file51">codeberg.org/snonux/hexai/internal/tmuxedit/cursor_agent.go (75.0%)</option> + + <option value="file52">codeberg.org/snonux/hexai/internal/tmuxedit/pane.go (92.3%)</option> + + <option value="file53">codeberg.org/snonux/hexai/internal/tmuxedit/run.go (56.8%)</option> + + <option value="file54">codeberg.org/snonux/hexai/internal/tmuxedit/send.go (56.2%)</option> + + </select> + </div> + <div id="legend"> + <span>not tracked</span> + + <span class="cov0">no coverage</span> + <span class="cov1">low coverage</span> + <span class="cov2">*</span> + <span class="cov3">*</span> + <span class="cov4">*</span> + <span class="cov5">*</span> + <span class="cov6">*</span> + <span class="cov7">*</span> + <span class="cov8">*</span> + <span class="cov9">*</span> + <span class="cov10">high coverage</span> + + </div> + </div> + <div id="content"> + + <pre class="file" id="file0" style="display: none">// Summary: Hexai LSP entrypoint; parses flags and delegates to internal/hexailsp. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/hexailsp" +) + +func main() <span class="cov10" title="3">{ + logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") + defaultCfg := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultCfg)) + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion </span><span class="cov10" title="3">{ + log.Println(internal.Version) + return + }</span> + + <span class="cov0" title="0">path := strings.TrimSpace(*configPath) + if err := hexailsp.RunWithConfig(*logPath, path, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + log.Fatalf("server error: %v", err) + }</span> +} + +func defaultConfigPath() string <span class="cov10" title="3">{ + path, err := appconfig.ConfigPath() + if err != nil </span><span class="cov0" title="0">{ + return "$XDG_CONFIG_HOME/hexai/config.toml" + }</span> + <span class="cov10" title="3">return path</span> +} +</pre> + + <pre class="file" id="file1" style="display: none">package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/hexaiaction" +) + +func main() <span class="cov0" title="0">{ + infile := flag.String("infile", "", "Read input from this file instead of stdin") + outfile := flag.String("outfile", "", "Write output to this file instead of stdout") + uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") + defaultPath := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + 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() + + opts := hexaiaction.Options{ + Infile: *infile, Outfile: *outfile, + UIChild: *uiChild, TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent, + } + ctx := context.Background() + if path := strings.TrimSpace(*configPath); path != "" </span><span class="cov0" title="0">{ + ctx = hexaiaction.WithConfigPath(ctx, path) + }</span> + <span class="cov0" title="0">if err := hexaiaction.RunCommand(ctx, opts, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> +} + +func defaultConfigPath() string <span class="cov0" title="0">{ + path, err := appconfig.ConfigPath() + if err != nil </span><span class="cov0" title="0">{ + return "$XDG_CONFIG_HOME/hexai/config.toml" + }</span> + <span class="cov0" title="0">return path</span> +} +</pre> + + <pre class="file" id="file2" style="display: none">// hexai-tmux-edit opens a tmux popup with $EDITOR for composing AI agent +// prompts. It captures existing prompt text from the target pane, pre-fills +// the editor, and sends the edited text back via tmux send-keys. +// +// Usage: +// +// hexai-tmux-edit [--config <path>] [--agent <name>] [--pane <id>] +// +// Tmux keybinding (add to ~/.tmux.conf): +// +// bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'" +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/tmuxedit" +) + +func main() <span class="cov0" title="0">{ + defaultPath := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + agent := flag.String("agent", "", "AI agent name (auto-detected if omitted)") + pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)") + flag.Parse() + + opts := tmuxedit.Options{ + ConfigPath: strings.TrimSpace(*configPath), + Agent: strings.TrimSpace(*agent), + Pane: strings.TrimSpace(*pane), + } + if err := tmuxedit.Run(opts); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> +} + +func defaultConfigPath() string <span class="cov0" title="0">{ + path, err := appconfig.ConfigPath() + if err != nil </span><span class="cov0" title="0">{ + return "$XDG_CONFIG_HOME/hexai/config.toml" + }</span> + <span class="cov0" title="0">return path</span> +} +</pre> + + <pre class="file" id="file3" style="display: none">// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "os" + "strconv" + "strings" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/hexaicli" +) + +func main() <span class="cov8" title="1">{ + configPath, remaining := splitConfigPath(os.Args[1:]) + logger := log.New(io.Discard, "", 0) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) + cliEntries := cfg.CLIConfigs + if len(cliEntries) == 0 </span><span class="cov8" title="1">{ + cliEntries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} + }</span> + <span class="cov8" title="1">fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + defaultPath := defaultConfigPath() + configFlag := fs.String("config", configPath, fmt.Sprintf("path to config file (default: %s)", defaultPath)) + showVersion := fs.Bool("version", false, "print version and exit") + selectedFlags := make([]bool, len(cliEntries)) + for i, entry := range cliEntries </span><span class="cov8" title="1">{ + name := strconv.Itoa(i) + provider := strings.TrimSpace(entry.Provider) + if provider == "" </span><span class="cov8" title="1">{ + provider = cfg.Provider + }</span> + <span class="cov8" title="1">model := strings.TrimSpace(entry.Model) + if model == "" </span><span class="cov8" title="1">{ + model = pickDefaultModel(cfg, provider) + }</span> + <span class="cov8" title="1">desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) + fs.BoolVar(&selectedFlags[i], name, false, desc)</span> + } + <span class="cov8" title="1">_ = fs.Parse(remaining) + if *showVersion </span><span class="cov8" title="1">{ + fmt.Fprintln(os.Stdout, internal.Version) + return + }</span> + <span class="cov0" title="0">var selection []int + for i, sel := range selectedFlags </span><span class="cov0" title="0">{ + if sel </span><span class="cov0" title="0">{ + selection = append(selection, i) + }</span> + } + <span class="cov0" title="0">finalPath := strings.TrimSpace(*configFlag) + if finalPath == "" </span><span class="cov0" title="0">{ + finalPath = configPath + }</span> + <span class="cov0" title="0">ctx := context.Background() + if finalPath != "" </span><span class="cov0" title="0">{ + ctx = hexaicli.WithCLIConfigPath(ctx, finalPath) + }</span> + <span class="cov0" title="0">if len(selection) > 0 </span><span class="cov0" title="0">{ + ctx = hexaicli.WithCLISelection(ctx, selection) + }</span> + <span class="cov0" title="0">if err := hexaicli.Run(ctx, fs.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + os.Exit(1) + }</span> +} + +func splitConfigPath(args []string) (string, []string) <span class="cov8" title="1">{ + var path string + rest := make([]string, 0, len(args)) + skip := false + for i := 0; i < len(args); i++ </span><span class="cov8" title="1">{ + if skip </span><span class="cov0" title="0">{ + skip = false + continue</span> + } + <span class="cov8" title="1">arg := args[i] + switch </span>{ + case arg == "--config" || arg == "-config":<span class="cov0" title="0"> + if i+1 < len(args) </span><span class="cov0" title="0">{ + path = args[i+1] + skip = true + }</span> + case strings.HasPrefix(arg, "--config="):<span class="cov0" title="0"> + path = arg[len("--config="):]</span> + case strings.HasPrefix(arg, "-config="):<span class="cov0" title="0"> + path = arg[len("-config="):]</span> + default:<span class="cov8" title="1"> + rest = append(rest, arg)</span> + } + } + <span class="cov8" title="1">return strings.TrimSpace(path), rest</span> +} + +func pickDefaultModel(cfg appconfig.App, provider string) string <span class="cov8" title="1">{ + switch strings.ToLower(strings.TrimSpace(provider)) </span>{ + case "ollama":<span class="cov0" title="0"> + return strings.TrimSpace(cfg.OllamaModel)</span> + case "anthropic":<span class="cov0" title="0"> + return strings.TrimSpace(cfg.AnthropicModel)</span> + default:<span class="cov8" title="1"> + return strings.TrimSpace(cfg.OpenAIModel)</span> + } +} + +func defaultConfigPath() string <span class="cov8" title="1">{ + cfgPath, err := appconfig.ConfigPath() + if err != nil </span><span class="cov0" title="0">{ + return "$XDG_CONFIG_HOME/hexai/config.toml" + }</span> + <span class="cov8" title="1">return cfgPath</span> +} +</pre> + + <pre class="file" id="file4" style="display: none">// 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" +) + +// SurfaceConfig describes a provider/model pairing (with optional temperature). +type SurfaceConfig struct { + Provider string + Model string + Temperature *float64 +} + +// 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"` + RequestTimeout int `json:"request_timeout" toml:"request_timeout"` + // 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"` + // CompletionWaitAll controls whether to wait for all configured completion + // backends before returning results. When true (default), waits for all + // backends. When false, returns the first result immediately. + CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"` + + 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"` + OpenRouterBaseURL string `json:"openrouter_base_url" toml:"openrouter_base_url"` + OpenRouterModel string `json:"openrouter_model" toml:"openrouter_model"` + // Default temperature for OpenRouter requests (nil means use provider default) + OpenRouterTemperature *float64 `json:"openrouter_temperature" toml:"openrouter_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"` + AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` + AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` + // Default temperature for Anthropic requests (nil means use provider default) + AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` + + // Per-surface provider/model configurations (ordered; first entry is primary) + CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` + CodeActionConfigs []SurfaceConfig `json:"-" toml:"-"` + ChatConfigs []SurfaceConfig `json:"-" toml:"-"` + CLIConfigs []SurfaceConfig `json:"-" toml:"-"` + + // 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:"-"` + PromptCodeActionSimplifySystem string `json:"-" toml:"-"` + PromptCodeActionSimplifyUser string `json:"-" toml:"-"` + // CLI + PromptCLIDefaultSystem string `json:"-" toml:"-"` + PromptCLIExplainSystem string `json:"-" toml:"-"` + + // Custom code actions and tmux integration + CustomActions []CustomAction `json:"-" toml:"-"` + TmuxCustomMenuHotkey string `json:"-" toml:"-"` + // Stats + StatsWindowMinutes int `json:"-" toml:"-"` + + // Ignore: gitignore-aware file filtering for LSP + IgnoreGitignore *bool `json:"-" toml:"-"` + IgnoreExtraPatterns []string `json:"-" toml:"-"` + IgnoreLSPNotify *bool `json:"-" toml:"-"` + + // TmuxEdit: popup editor settings for hexai-tmux-edit + TmuxEditPopupWidth string `json:"-" toml:"-"` + TmuxEditPopupHeight string `json:"-" toml:"-"` + TmuxEditDefaultAgent string `json:"-" toml:"-"` + TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` +} + +// CustomAction describes a user-defined code action. +type CustomAction struct { + ID string + Title string + Kind string // optional; default "refactor" + Scope string // "selection" (default) | "diagnostics" + Hotkey string // optional, used by tmux submenu + Instruction string // optional; if set and User is empty, use global rewrite templates + System string // optional; used only when User is set + User string // optional; if set, render with available vars +} + +// TmuxEditAgentCfg describes an AI agent's detection and interaction patterns +// for the tmux popup editor (hexai-tmux-edit). +type TmuxEditAgentCfg struct { + Name string + DisplayName string + DetectPattern string + SectionPattern string + PromptPattern string + StripPatterns []string + ClearFirst *bool + ClearKeys string + NewlineKeys string + SubmitKeys string +} + +// Constructor: defaults for App (kept first among functions) +func newDefaultConfig() App <span class="cov5" title="62">{ + // 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, + RequestTimeout: 30, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + AnthropicTemperature: &t, + ManualInvokeMinPrefix: 0, + CompletionDebounceMs: 800, + 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}}", + PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", + PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", + + 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.", + + // Stats + StatsWindowMinutes: 60, + + // Ignore: respect .gitignore by default, notify in LSP by default + IgnoreGitignore: boolPtr(true), + IgnoreLSPNotify: boolPtr(true), + } +}</span> + +func boolPtr(b bool) *bool <span class="cov6" title="124">{ return &b }</span> + +// Load reads configuration from a file and merges with defaults. +// It respects the XDG Base Directory Specification. +func Load(logger *log.Logger) App <span class="cov5" title="36">{ return LoadWithOptions(logger, LoadOptions{}) }</span> + +// LoadOptions tune how configuration is loaded at runtime. +type LoadOptions struct { + // IgnoreEnv skips applying environment overrides when true. + IgnoreEnv bool + // ConfigPath overrides the global config file path (e.g. via --config flag). + ConfigPath string + // ProjectRoot overrides the project root directory for locating .hexaiconfig.toml. + // When empty, findGitRoot() is used to auto-detect from the current working directory. + ProjectRoot string +} + +// LoadWithOptions reads configuration and applies the requested loading options. +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App <span class="cov5" title="60">{ + cfg := newDefaultConfig() + if logger == nil </span><span class="cov4" title="14">{ + return cfg // Return defaults if no logger is provided (e.g. in tests) + }</span> + + // Step 1: Load global config file + <span class="cov5" title="46">configPath := strings.TrimSpace(opts.ConfigPath) + if configPath != "" </span><span class="cov3" title="6">{ + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov3" title="6">{ + cfg.mergeWith(fileCfg) + }</span> else<span class="cov0" title="0"> if err != nil </span><span class="cov0" title="0">{ + logger.Printf("cannot open config file %s: %v", configPath, err) + }</span> + } else<span class="cov5" title="40"> { + path, err := getConfigPath() + if err != nil </span><span class="cov0" title="0">{ + logger.Printf("%v", err) + }</span> else<span class="cov5" title="40"> if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil </span><span class="cov4" title="24">{ + cfg.mergeWith(fileCfg) + }</span> + } + + // Step 2: Load per-project config (.hexaiconfig.toml at git repo root). + // Project config overrides global config but is itself overridden by env vars. + <span class="cov5" title="46">loadProjectConfig(logger, opts, &cfg) + + // Step 3: Environment overrides (always take precedence over all config files) + if !opts.IgnoreEnv </span><span class="cov5" title="42">{ + if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov3" title="11">{ + cfg.mergeWith(envCfg) + }</span> + } + <span class="cov5" title="46">return cfg</span> +} + +// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and +// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot(). +func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) <span class="cov5" title="46">{ + projectRoot := strings.TrimSpace(opts.ProjectRoot) + if projectRoot == "" </span><span class="cov5" title="43">{ + projectRoot = FindGitRoot() + }</span> + <span class="cov5" title="46">if projectRoot == "" </span><span class="cov1" title="1">{ + return + }</span> + <span class="cov5" title="45">projectCfgPath := filepath.Join(projectRoot, ProjectConfigFilename) + if projCfg, err := loadFromFile(projectCfgPath, logger); err == nil && projCfg != nil </span><span class="cov2" title="3">{ + cfg.mergeWith(projCfg) + }</span> +} + +// 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"` + OpenRouter sectionOpenRouter `toml:"openrouter"` + Ollama sectionOllama `toml:"ollama"` + Anthropic sectionAnthropic `toml:"anthropic"` + Prompts sectionPrompts `toml:"prompts"` + Tmux sectionTmux `toml:"tmux"` + Stats sectionStats `toml:"stats"` + Ignore sectionIgnore `toml:"ignore"` + TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` +} + +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"` + RequestTimeout int `toml:"request_timeout"` +} + +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"` + CompletionWaitAll *bool `toml:"completion_wait_all"` +} + +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 sectionStats struct { + WindowMinutes int `toml:"window_minutes"` +} + +// sectionIgnore controls gitignore-aware file filtering. Files matching +// these patterns are skipped for completions and code actions. +type sectionIgnore struct { + Gitignore *bool `toml:"gitignore"` + ExtraPatterns []string `toml:"extra_patterns"` + LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"` +} + +// sectionTmuxEdit configures the tmux popup editor feature (hexai-tmux-edit). +type sectionTmuxEdit struct { + PopupWidth string `toml:"popup_width"` + PopupHeight string `toml:"popup_height"` + DefaultAgent string `toml:"default_agent"` + Agents []sectionTmuxEditAgent `toml:"agents"` +} + +// sectionTmuxEditAgent defines detection and interaction patterns for one AI agent. +type sectionTmuxEditAgent struct { + Name string `toml:"name"` + DisplayName string `toml:"display_name"` + DetectPattern string `toml:"detect_pattern"` + SectionPattern string `toml:"section_pattern"` + PromptPattern string `toml:"prompt_pattern"` + StripPatterns []string `toml:"strip_patterns"` + ClearFirst *bool `toml:"clear_first"` + ClearKeys string `toml:"clear_keys"` + NewlineKeys string `toml:"newline_keys"` + SubmitKeys string `toml:"submit_keys"` +} + +type sectionOpenAI struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` + Presets map[string]string `toml:"presets"` +} + +func (s sectionOpenAI) isZero() bool <span class="cov5" title="35">{ + return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 +}</span> + +func (s sectionOpenAI) resolvedModel() string <span class="cov3" title="6">{ + model := strings.TrimSpace(s.Model) + if model == "" </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov3" title="6">if len(s.Presets) == 0 </span><span class="cov2" title="5">{ + return model + }</span> + <span class="cov1" title="1">if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" </span><span class="cov1" title="1">{ + return mapped + }</span> + <span class="cov0" title="0">lower := strings.ToLower(model) + for k, v := range s.Presets </span><span class="cov0" title="0">{ + if strings.ToLower(strings.TrimSpace(k)) == lower </span><span class="cov0" title="0">{ + if mapped := strings.TrimSpace(v); mapped != "" </span><span class="cov0" title="0">{ + return mapped + }</span> + } + } + <span class="cov0" title="0">return model</span> +} + +type sectionOpenRouter 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"` +} + +type sectionAnthropic 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"` + SimplifySystem string `toml:"simplify_system"` + SimplifyUser string `toml:"simplify_user"` + Custom []sectionCustomAction `toml:"custom"` +} + +type sectionPromptsCLI struct { + DefaultSystem string `toml:"default_system"` + ExplainSystem string `toml:"explain_system"` +} + +type sectionPromptsProviderNative struct { + Completion string `toml:"completion"` +} + +type sectionCustomAction struct { + ID string `toml:"id"` + Title string `toml:"title"` + Kind string `toml:"kind"` + Scope string `toml:"scope"` + Hotkey string `toml:"hotkey"` + Instruction string `toml:"instruction"` + System string `toml:"system"` + User string `toml:"user"` +} + +type sectionTmux struct { + CustomMenuHotkey string `toml:"custom_menu_hotkey"` +} + +func (fc *fileConfig) toApp() App <span class="cov5" title="35">{ + out := App{} + + // Merge section: general + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil </span><span class="cov4" title="17">{ + tmp := App{ + MaxTokens: fc.General.MaxTokens, + ContextMode: fc.General.ContextMode, + ContextWindowLines: fc.General.ContextWindowLines, + MaxContextTokens: fc.General.MaxContextTokens, + CodingTemperature: fc.General.CodingTemperature, + RequestTimeout: fc.General.RequestTimeout, + } + out.mergeBasics(&tmp) + }</span> + + // logging + <span class="cov5" title="35">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{ + tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} + out.mergeBasics(&tmp) + }</span> + + // completion + <span class="cov5" title="35">if fc.Completion.CompletionDebounceMs != 0 || fc.Completion.CompletionThrottleMs != 0 || + fc.Completion.ManualInvokeMinPrefix != 0 || fc.Completion.CompletionWaitAll != nil </span><span class="cov2" title="4">{ + tmp := App{ + CompletionDebounceMs: fc.Completion.CompletionDebounceMs, + CompletionThrottleMs: fc.Completion.CompletionThrottleMs, + ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, + CompletionWaitAll: fc.Completion.CompletionWaitAll, + } + out.mergeBasics(&tmp) + }</span> + + // triggers + <span class="cov5" title="35">if len(fc.Triggers.TriggerCharacters) > 0 </span><span class="cov2" title="4">{ + tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} + out.mergeBasics(&tmp) + }</span> + + // inline + <span class="cov5" title="35">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{ + tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} + out.mergeBasics(&tmp) + }</span> + + // chat + <span class="cov5" title="35">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 </span><span class="cov1" title="1">{ + tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} + out.mergeBasics(&tmp) + }</span> + + // provider + <span class="cov5" title="35">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov3" title="8">{ + tmp := App{Provider: fc.Provider.Name} + out.mergeBasics(&tmp) + }</span> + + // openai + <span class="cov5" title="35">if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil </span><span class="cov3" title="6">{ + tmp := App{ + OpenAIBaseURL: fc.OpenAI.BaseURL, + OpenAIModel: fc.OpenAI.resolvedModel(), + OpenAITemperature: fc.OpenAI.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + + // openrouter + <span class="cov5" title="35">if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil </span><span class="cov0" title="0">{ + tmp := App{ + OpenRouterBaseURL: fc.OpenRouter.BaseURL, + OpenRouterModel: fc.OpenRouter.Model, + OpenRouterTemperature: fc.OpenRouter.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + + // ollama + <span class="cov5" title="35">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="4">{ + tmp := App{ + OllamaBaseURL: fc.Ollama.BaseURL, + OllamaModel: fc.Ollama.Model, + OllamaTemperature: fc.Ollama.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + + // anthropic + <span class="cov5" title="35">if (fc.Anthropic != sectionAnthropic{}) || fc.Anthropic.Temperature != nil </span><span class="cov0" title="0">{ + tmp := App{ + AnthropicBaseURL: fc.Anthropic.BaseURL, + AnthropicModel: fc.Anthropic.Model, + AnthropicTemperature: fc.Anthropic.Temperature, + } + out.mergeProviderFields(&tmp) + }</span> + + // prompts + // completion + <span class="cov5" title="35">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{ + if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionSystemParams = fc.Prompts.Completion.SystemParams + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionSystemInline = fc.Prompts.Completion.SystemInline + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionUserGeneral = fc.Prompts.Completion.UserGeneral + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionUserParams = fc.Prompts.Completion.UserParams + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" </span><span class="cov1" title="1">{ + out.PromptCompletionExtraHeader = fc.Prompts.Completion.ExtraHeader + }</span> + } + // chat + <span class="cov5" title="35">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{ + out.PromptChatSystem = fc.Prompts.Chat.System + }</span> + // code action + <span class="cov5" title="35">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || + len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov3" title="7">{ + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov1" title="1">{ + out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{ + out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem + }</span> + <span class="cov3" title="7">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" </span><span class="cov0" title="0">{ + out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser + }</span> + <span class="cov3" title="7">if len(fc.Prompts.CodeAction.Custom) > 0 </span><span class="cov3" title="6">{ + for _, ca := range fc.Prompts.CodeAction.Custom </span><span class="cov3" title="10">{ + out.CustomActions = append(out.CustomActions, CustomAction{ + ID: strings.TrimSpace(ca.ID), + Title: strings.TrimSpace(ca.Title), + Kind: strings.TrimSpace(ca.Kind), + Scope: strings.ToLower(strings.TrimSpace(ca.Scope)), + Hotkey: strings.TrimSpace(ca.Hotkey), + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + }</span> + } + } + // cli + <span class="cov5" title="35">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{ + if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem + }</span> + <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" </span><span class="cov1" title="1">{ + out.PromptCLIExplainSystem = fc.Prompts.CLI.ExplainSystem + }</span> + } + // provider-native + <span class="cov5" title="35">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{ + out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion + }</span> + + // tmux + <span class="cov5" title="35">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{ + out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) + }</span> + + // stats + <span class="cov5" title="35">if fc.Stats.WindowMinutes > 0 </span><span class="cov0" title="0">{ + out.StatsWindowMinutes = fc.Stats.WindowMinutes + }</span> + + // ignore + <span class="cov5" title="35">if fc.Ignore.Gitignore != nil || len(fc.Ignore.ExtraPatterns) > 0 || fc.Ignore.LSPNotifyIgnored != nil </span><span class="cov2" title="5">{ + tmp := App{ + IgnoreGitignore: fc.Ignore.Gitignore, + IgnoreExtraPatterns: fc.Ignore.ExtraPatterns, + IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored, + } + out.mergeBasics(&tmp) + }</span> + + // tmux_edit + <span class="cov5" title="35">fc.applyTmuxEdit(&out) + + return out</span> +} + +// applyTmuxEdit converts the [tmux_edit] section into App fields. +func (fc *fileConfig) applyTmuxEdit(out *App) <span class="cov5" title="35">{ + te := fc.TmuxEdit + if strings.TrimSpace(te.PopupWidth) != "" </span><span class="cov1" title="1">{ + out.TmuxEditPopupWidth = strings.TrimSpace(te.PopupWidth) + }</span> + <span class="cov5" title="35">if strings.TrimSpace(te.PopupHeight) != "" </span><span class="cov1" title="1">{ + out.TmuxEditPopupHeight = strings.TrimSpace(te.PopupHeight) + }</span> + <span class="cov5" title="35">if strings.TrimSpace(te.DefaultAgent) != "" </span><span class="cov1" title="1">{ + out.TmuxEditDefaultAgent = strings.TrimSpace(te.DefaultAgent) + }</span> + <span class="cov5" title="35">for _, a := range te.Agents </span><span class="cov2" title="3">{ + if strings.TrimSpace(a.Name) == "" </span><span class="cov1" title="1">{ + continue</span> + } + <span class="cov1" title="2">out.TmuxEditAgents = append(out.TmuxEditAgents, TmuxEditAgentCfg{ + Name: strings.TrimSpace(a.Name), + DisplayName: strings.TrimSpace(a.DisplayName), + DetectPattern: strings.TrimSpace(a.DetectPattern), + SectionPattern: strings.TrimSpace(a.SectionPattern), + PromptPattern: strings.TrimSpace(a.PromptPattern), + StripPatterns: a.StripPatterns, + ClearFirst: a.ClearFirst, + ClearKeys: strings.TrimSpace(a.ClearKeys), + NewlineKeys: strings.TrimSpace(a.NewlineKeys), + SubmitKeys: strings.TrimSpace(a.SubmitKeys), + })</span> + } +} + +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov6" title="94">{ + b, err := os.ReadFile(path) + if err != nil </span><span class="cov5" title="58">{ + if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ + logger.Printf("cannot open TOML config file %s: %v", path, err) + }</span> + <span class="cov5" title="58">return nil, err</span> + } + + <span class="cov5" title="36">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 </span><span class="cov1" title="1">{ + if logger != nil </span><span class="cov1" title="1">{ + logger.Printf("invalid TOML config file %s: %v", path, errTables) + }</span> + <span class="cov1" title="1">return nil, errTables</span> + } + + // Reject legacy flat keys at top-level (sectioned-only config is allowed) + <span class="cov5" title="35">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": {}, + } + for k := range raw </span><span class="cov6" title="70">{ + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov5" title="60">{ + continue</span> + } + <span class="cov3" title="10">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k) + }</span> + } + + <span class="cov5" title="35">if logger != nil </span><span class="cov5" title="35">{ + logger.Printf("loaded configuration from %s (TOML)", path) + }</span> + + // Merge order: flat first, then tables (so tables win over zero flat values) + // Build App from tables only + <span class="cov5" title="35">tab := tables.toApp() + // Ensure explicit values from raw map are respected (defensive for ints) + if t, ok := raw["completion"].(map[string]any); ok </span><span class="cov2" title="4">{ + if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov2" title="4">{ + switch vv := v.(type) </span>{ + case int64:<span class="cov2" title="4"> + tab.ManualInvokeMinPrefix = int(vv)</span> + case int:<span class="cov0" title="0"> + tab.ManualInvokeMinPrefix = vv</span> + case float64:<span class="cov0" title="0"> + tab.ManualInvokeMinPrefix = int(vv)</span> + } + } + } + <span class="cov5" title="35">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="4">{ + if v, present := t["log_preview_limit"]; present </span><span class="cov2" title="4">{ + switch vv := v.(type) </span>{ + case int64:<span class="cov2" title="4"> + tab.LogPreviewLimit = int(vv)</span> + case int:<span class="cov0" title="0"> + tab.LogPreviewLimit = vv</span> + case float64:<span class="cov0" title="0"> + tab.LogPreviewLimit = int(vv)</span> + } + } + } + <span class="cov5" title="35">if m := parseSurfaceModels(raw, logger); m != nil </span><span class="cov2" title="4">{ + tab.mergeSurfaceModels(m) + }</span> + <span class="cov5" title="35">return &tab, nil</span> +} + +func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App <span class="cov5" title="35">{ + modelsRaw, ok := raw["models"] + if !ok </span><span class="cov5" title="31">{ + return nil + }</span> + <span class="cov2" title="4">table, ok := modelsRaw.(map[string]any) + if !ok </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: ignoring models section (expected table, got %T)", modelsRaw) + }</span> + <span class="cov0" title="0">return nil</span> + } + <span class="cov2" title="4">var out App + appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool </span><span class="cov4" title="16">{ + entries, ok := parseSurfaceEntries(val, key, logger) + if !ok || len(entries) == 0 </span><span class="cov2" title="3">{ + return false + }</span> + <span class="cov4" title="13">*dest = append(*dest, entries...) + return true</span> + } + <span class="cov2" title="4">any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) + if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok </span><span class="cov2" title="4">{ + if len(out.CodeActionConfigs) > 1 </span><span class="cov1" title="1">{ + if logger != nil </span><span class="cov1" title="1">{ + logger.Printf("config: models.code_action supports a single entry; ignoring %d extra", len(out.CodeActionConfigs)-1) + }</span> + <span class="cov1" title="1">out.CodeActionConfigs = out.CodeActionConfigs[:1]</span> + } + <span class="cov2" title="4">any = true</span> + } + <span class="cov2" title="4">any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any + any = appendEntries(&out.CLIConfigs, "models.cli", table["cli"]) || any + if !any </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov2" title="4">return &out</span> +} + +func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) <span class="cov4" title="16">{ + switch v := raw.(type) </span>{ + case nil:<span class="cov2" title="3"> + return nil, false</span> + case []any:<span class="cov4" title="13"> + var out []SurfaceConfig + for i, entry := range v </span><span class="cov4" title="14">{ + cfg, ok := decodeModelEntry(entry, fmt.Sprintf("%s[%d]", path, i), logger) + if !ok || cfg == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov4" title="14">out = append(out, *cfg)</span> + } + <span class="cov4" title="13">return out, len(out) > 0</span> + default:<span class="cov0" title="0"> + if cfg, ok := decodeModelEntry(v, path, logger); ok && cfg != nil </span><span class="cov0" title="0">{ + return []SurfaceConfig{*cfg}, true + }</span> + <span class="cov0" title="0">return nil, false</span> + } +} + +func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig <span class="cov4" title="27">{ + if len(src) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov4" title="27">out := make([]SurfaceConfig, len(src)) + copy(out, src) + return out</span> +} + +func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) <span class="cov4" title="14">{ + if raw == nil </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="14">switch v := raw.(type) </span>{ + case string:<span class="cov0" title="0"> + model := strings.TrimSpace(v) + if model == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov0" title="0">return &SurfaceConfig{Model: model}, true</span> + case map[string]any:<span class="cov4" title="14"> + model := "" + provider := "" + if m, ok := v["model"]; ok </span><span class="cov4" title="14">{ + s, ok := m.(string) + if !ok </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.model must be a string", path) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov4" title="14">model = strings.TrimSpace(s)</span> + } + <span class="cov4" title="14">if pRaw, ok := v["provider"]; ok </span><span class="cov4" title="14">{ + ps, ok := pRaw.(string) + if !ok </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.provider must be a string", path) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov4" title="14">provider = strings.TrimSpace(ps)</span> + } + <span class="cov4" title="14">var tempPtr *float64 + if tRaw, ok := v["temperature"]; ok </span><span class="cov3" title="6">{ + parsed, ok := parseTemperatureValue(tRaw, path, logger) + if !ok </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov3" title="6">tempPtr = parsed</span> + } + <span class="cov4" title="14">if model == "" && tempPtr == nil && provider == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="14">return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true</span> + default:<span class="cov0" title="0"> + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s must be a string or table, got %T", path, raw) + }</span> + <span class="cov0" title="0">return nil, false</span> + } +} + +func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) <span class="cov3" title="6">{ + switch v := raw.(type) </span>{ + case float64:<span class="cov3" title="6"> + return floatPtr(v), true</span> + case int64:<span class="cov0" title="0"> + return floatPtr(float64(v)), true</span> + case string:<span class="cov0" title="0"> + s := strings.TrimSpace(v) + if s == "" </span><span class="cov0" title="0">{ + return nil, true + }</span> + <span class="cov0" title="0">f, err := strconv.ParseFloat(s, 64) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.temperature invalid: %v", path, err) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov0" title="0">return floatPtr(f), true</span> + default:<span class="cov0" title="0"> + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("config: %s.temperature must be numeric or string, got %T", path, raw) + }</span> + <span class="cov0" title="0">return nil, false</span> + } +} + +func floatPtr(v float64) *float64 <span class="cov3" title="6">{ + f := v + return &f +}</span> + +func (a *App) mergeWith(other *App) <span class="cov5" title="45">{ + a.mergeBasics(other) + a.mergeProviderFields(other) + a.mergeSurfaceModels(other) + a.mergePrompts(other) + a.mergeTmuxEdit(other) +}</span> + +// mergeBasics merges general (non-provider) fields. +func (a *App) mergeBasics(other *App) <span class="cov6" title="86">{ + if other.MaxTokens > 0 </span><span class="cov5" title="38">{ + a.MaxTokens = other.MaxTokens + }</span> + <span class="cov6" title="86">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="10">{ + a.ContextMode = s + }</span> + <span class="cov6" title="86">if other.ContextWindowLines > 0 </span><span class="cov3" title="8">{ + a.ContextWindowLines = other.ContextWindowLines + }</span> + <span class="cov6" title="86">if other.MaxContextTokens > 0 </span><span class="cov3" title="8">{ + a.MaxContextTokens = other.MaxContextTokens + }</span> + <span class="cov6" title="86">if other.LogPreviewLimit >= 0 </span><span class="cov6" title="86">{ + a.LogPreviewLimit = other.LogPreviewLimit + }</span> + <span class="cov6" title="86">if other.RequestTimeout > 0 </span><span class="cov0" title="0">{ + a.RequestTimeout = other.RequestTimeout + }</span> + <span class="cov6" title="86">if other.CodingTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + }</span> + <span class="cov6" title="86">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov6" title="86">{ + a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix + }</span> + <span class="cov6" title="86">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="8">{ + a.CompletionDebounceMs = other.CompletionDebounceMs + }</span> + <span class="cov6" title="86">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="8">{ + a.CompletionThrottleMs = other.CompletionThrottleMs + }</span> + <span class="cov6" title="86">if other.CompletionWaitAll != nil </span><span class="cov0" title="0">{ + a.CompletionWaitAll = other.CompletionWaitAll + }</span> + <span class="cov6" title="86">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="8">{ + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + }</span> + <span class="cov6" title="86">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov1" title="2">{ + a.InlineOpen = s + }</span> + <span class="cov6" title="86">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov1" title="2">{ + a.InlineClose = s + }</span> + <span class="cov6" title="86">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov1" title="2">{ + a.ChatSuffix = s + }</span> + <span class="cov6" title="86">if len(other.ChatPrefixes) > 0 </span><span class="cov1" title="2">{ + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + }</span> + <span class="cov6" title="86">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="20">{ + a.Provider = s + }</span> + // Ignore settings + <span class="cov6" title="86">if other.IgnoreGitignore != nil </span><span class="cov3" title="11">{ + a.IgnoreGitignore = other.IgnoreGitignore + }</span> + <span class="cov6" title="86">if len(other.IgnoreExtraPatterns) > 0 </span><span class="cov2" title="5">{ + a.IgnoreExtraPatterns = slices.Clone(other.IgnoreExtraPatterns) + }</span> + <span class="cov6" title="86">if other.IgnoreLSPNotify != nil </span><span class="cov2" title="5">{ + a.IgnoreLSPNotify = other.IgnoreLSPNotify + }</span> +} + +// mergeSurfaceModels copies per-surface model and temperature overrides. +func (a *App) mergeSurfaceModels(other *App) <span class="cov5" title="49">{ + if len(other.CompletionConfigs) > 0 </span><span class="cov3" title="7">{ + a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) + }</span> + <span class="cov5" title="49">if len(other.CodeActionConfigs) > 0 </span><span class="cov3" title="7">{ + a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) + }</span> + <span class="cov5" title="49">if len(other.ChatConfigs) > 0 </span><span class="cov3" title="6">{ + a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) + }</span> + <span class="cov5" title="49">if len(other.CLIConfigs) > 0 </span><span class="cov3" title="7">{ + a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) + }</span> +} + +// mergePrompts copies non-empty prompt templates from other. +func (a *App) mergePrompts(other *App) <span class="cov5" title="45">{ + // Completion + if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionSystemParams = other.PromptCompletionSystemParams + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionSystemInline = other.PromptCompletionSystemInline + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionUserParams = other.PromptCompletionUserParams + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{ + a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader + }</span> + // Provider-native + <span class="cov5" title="45">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{ + a.PromptNativeCompletion = other.PromptNativeCompletion + }</span> + // Chat + <span class="cov5" title="45">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{ + a.PromptChatSystem = other.PromptChatSystem + }</span> + // Code actions + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{ + a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{ + a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{ + a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser + }</span> + // CLI + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + a.PromptCLIExplainSystem = other.PromptCLIExplainSystem + }</span> + // Custom actions + <span class="cov5" title="45">if len(other.CustomActions) > 0 </span><span class="cov3" title="6">{ + a.CustomActions = append([]CustomAction{}, other.CustomActions...) + }</span> + <span class="cov5" title="45">if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" </span><span class="cov2" title="3">{ + a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey + }</span> +} + +// Validate checks custom actions and tmux settings for duplicates and consistency. +// mergeTmuxEdit copies non-empty tmux edit settings from other. +func (a *App) mergeTmuxEdit(other *App) <span class="cov5" title="45">{ + if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" </span><span class="cov1" title="2">{ + a.TmuxEditPopupWidth = s + }</span> + <span class="cov5" title="45">if s := strings.TrimSpace(other.TmuxEditPopupHeight); s != "" </span><span class="cov1" title="1">{ + a.TmuxEditPopupHeight = s + }</span> + <span class="cov5" title="45">if s := strings.TrimSpace(other.TmuxEditDefaultAgent); s != "" </span><span class="cov1" title="2">{ + a.TmuxEditDefaultAgent = s + }</span> + <span class="cov5" title="45">if len(other.TmuxEditAgents) > 0 </span><span class="cov1" title="2">{ + a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, other.TmuxEditAgents...) + }</span> +} + +func (a App) Validate() error <span class="cov4" title="24">{ + // Normalize and check duplicates for IDs and hotkeys + seenID := make(map[string]struct{}) + seenHK := make(map[string]struct{}) + for _, ca := range a.CustomActions </span><span class="cov3" title="9">{ + id := strings.ToLower(strings.TrimSpace(ca.ID)) + if id == "" </span><span class="cov1" title="1">{ + return fmt.Errorf("config: custom action missing required field id") + }</span> + <span class="cov3" title="8">if _, ok := seenID[id]; ok </span><span class="cov1" title="1">{ + return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) + }</span> + <span class="cov3" title="7">seenID[id] = struct{}{} + if strings.TrimSpace(ca.Title) == "" </span><span class="cov0" title="0">{ + return fmt.Errorf("config: custom action %s missing required field title", ca.ID) + }</span> + // Validate scope + <span class="cov3" title="7">scope := strings.TrimSpace(ca.Scope) + if scope != "" && scope != "selection" && scope != "diagnostics" </span><span class="cov1" title="1">{ + return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) + }</span> + // Instruction vs user + <span class="cov3" title="6">hasInstr := strings.TrimSpace(ca.Instruction) != "" + hasUser := strings.TrimSpace(ca.User) != "" + if hasInstr && hasUser </span><span class="cov0" title="0">{ + return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) + }</span> + <span class="cov3" title="6">if !hasInstr && !hasUser </span><span class="cov0" title="0">{ + return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) + }</span> + // Hotkey unique (case-insensitive), one rune if provided + <span class="cov3" title="6">if hk := strings.TrimSpace(ca.Hotkey); hk != "" </span><span class="cov2" title="5">{ + if []rune(hk) == nil || len([]rune(hk)) != 1 </span><span class="cov1" title="1">{ + return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) + }</span> + <span class="cov2" title="4">lhk := strings.ToLower(hk) + if _, ok := seenHK[lhk]; ok </span><span class="cov1" title="1">{ + return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) + }</span> + <span class="cov2" title="3">seenHK[lhk] = struct{}{}</span> + } + } + // Tmux custom menu hotkey validation + <span class="cov4" title="19">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov1" title="2">{ + if len([]rune(hk)) != 1 </span><span class="cov0" title="0">{ + return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) + }</span> + // built-in hotkeys in tmux TUI: r,i,c,t,p,s + <span class="cov1" title="2">switch strings.ToLower(hk) </span>{ + case "r", "i", "c", "t", "p", "s":<span class="cov1" title="1"> + return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk)</span> + } + } + <span class="cov4" title="18">return nil</span> +} + +// mergeProviderFields merges per-provider configuration. +func (a *App) mergeProviderFields(other *App) <span class="cov5" title="55">{ + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov3" title="8">{ + a.OpenAIBaseURL = s + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="16">{ + a.OpenAIModel = s + }</span> + <span class="cov5" title="55">if other.OpenAITemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + a.OpenAITemperature = other.OpenAITemperature + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" </span><span class="cov0" title="0">{ + a.OpenRouterBaseURL = s + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.OpenRouterModel); s != "" </span><span class="cov0" title="0">{ + a.OpenRouterModel = s + }</span> + <span class="cov5" title="55">if other.OpenRouterTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.OpenRouterTemperature = other.OpenRouterTemperature + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="8">{ + a.OllamaBaseURL = s + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="8">{ + a.OllamaModel = s + }</span> + <span class="cov5" title="55">if other.OllamaTemperature != nil </span><span class="cov3" title="8">{ // allow explicit 0.0 + a.OllamaTemperature = other.OllamaTemperature + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.AnthropicBaseURL); s != "" </span><span class="cov0" title="0">{ + a.AnthropicBaseURL = s + }</span> + <span class="cov5" title="55">if s := strings.TrimSpace(other.AnthropicModel); s != "" </span><span class="cov0" title="0">{ + a.AnthropicModel = s + }</span> + <span class="cov5" title="55">if other.AnthropicTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.AnthropicTemperature = other.AnthropicTemperature + }</span> +} + +func getConfigPath() (string, error) <span class="cov5" title="41">{ + return ConfigPath() +}</span> + +// ConfigPath returns the default config file path ($XDG_CONFIG_HOME/hexai/config.toml or ~/.config/hexai/config.toml). +func ConfigPath() (string, error) <span class="cov5" title="45">{ + var configPath string + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov5" title="30">{ + configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") + }</span> else<span class="cov4" title="15"> { + home, err := os.UserHomeDir() + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("cannot find user home directory: %v", err) + }</span> + <span class="cov4" title="15">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> + } + <span class="cov5" title="45">return configPath, nil</span> +} + +// ProjectConfigFilename is the name of the per-project config file placed at a git repo root. +const ProjectConfigFilename = ".hexaiconfig.toml" + +// ProjectConfigPath returns the path to the per-project config file if a git repository +// root is detected from the current working directory. Returns empty string otherwise. +func ProjectConfigPath() string <span class="cov1" title="2">{ + root := FindGitRoot() + if root == "" </span><span class="cov1" title="1">{ + return "" + }</span> + <span class="cov1" title="1">return filepath.Join(root, ProjectConfigFilename)</span> +} + +// FindGitRoot walks up from the current working directory to find the nearest +// .git directory or file (worktrees use a .git file), returning its parent +// path or "" if none is found. +func FindGitRoot() string <span class="cov5" title="55">{ + dir, err := os.Getwd() + if err != nil </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov5" title="55">for </span><span class="cov7" title="167">{ + if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && + (info.IsDir() || info.Mode().IsRegular()) </span><span class="cov5" title="52">{ + return dir + }</span> + <span class="cov6" title="115">parent := filepath.Dir(dir) + if parent == dir </span><span class="cov2" title="3">{ + return "" // reached filesystem root + }</span> + <span class="cov6" title="112">dir = parent</span> + } +} + +// --- 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 <span class="cov5" title="42">{ + var out App + var any bool + + // helpers + getenv := func(k string) string </span><span class="cov10" title="1890">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov5" title="42">parseInt := func(k string) (int, bool) </span><span class="cov7" title="336">{ + v := getenv(k) + if v == "" </span><span class="cov7" title="325">{ + return 0, false + }</span> + <span class="cov3" title="11">n, err := strconv.Atoi(v) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid %s: %v", k, err) + }</span> + <span class="cov0" title="0">return 0, false</span> + } + <span class="cov3" title="11">return n, true</span> + } + <span class="cov5" title="42">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov8" title="378">{ + v := getenv(k) + if v == "" </span><span class="cov8" title="369">{ + return nil, false + }</span> + <span class="cov3" title="9">f, err := strconv.ParseFloat(v, 64) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid %s: %v", k, err) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov3" title="9">return &f, true</span> + } + + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov2" title="5">{ + out.MaxTokens = n + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + out.ContextMode = s + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + out.ContextWindowLines = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + out.MaxContextTokens = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + out.LogPreviewLimit = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_REQUEST_TIMEOUT"); ok </span><span class="cov0" title="0">{ + out.RequestTimeout = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + out.ManualInvokeMinPrefix = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + out.CompletionDebounceMs = n + any = true + }</span> + <span class="cov5" title="42">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + out.CompletionThrottleMs = n + any = true + }</span> + <span class="cov5" title="42">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.CodingTemperature = f + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + parts := strings.Split(s, ",") + out.TriggerCharacters = nil + for _, p := range parts </span><span class="cov2" title="3">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov2" title="3">{ + out.TriggerCharacters = append(out.TriggerCharacters, t) + }</span> + } + <span class="cov1" title="1">any = true</span> + } + <span class="cov5" title="42">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + out.InlineOpen = s + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + out.InlineClose = s + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + out.ChatSuffix = s + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts </span><span class="cov0" title="0">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ + out.ChatPrefixes = append(out.ChatPrefixes, t) + }</span> + } + <span class="cov0" title="0">any = true</span> + } + <span class="cov5" title="42">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov2" title="5">{ + out.Provider = s + any = true + }</span> + + <span class="cov5" title="42">modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + modelGeneric := strings.TrimSpace(getenv("HEXAI_MODEL")) + providerLower := strings.ToLower(strings.TrimSpace(out.Provider)) + forceUsed := false + genericUsed := false + pickModel := func(providerName, specific string) (string, bool) </span><span class="cov7" title="168">{ + specific = strings.TrimSpace(specific) + nameLower := strings.ToLower(strings.TrimSpace(providerName)) + if modelForce != "" </span><span class="cov2" title="4">{ + if providerLower == nameLower </span><span class="cov1" title="1">{ + forceUsed = true + return modelForce, true + }</span> + <span class="cov2" title="3">if providerLower == "" && !forceUsed </span><span class="cov0" title="0">{ + forceUsed = true + return modelForce, true + }</span> + } + <span class="cov7" title="167">if specific != "" </span><span class="cov2" title="3">{ + return specific, true + }</span> + <span class="cov7" title="164">if modelGeneric != "" </span><span class="cov3" title="11">{ + if providerLower == nameLower </span><span class="cov1" title="2">{ + return modelGeneric, true + }</span> + <span class="cov3" title="9">if providerLower == "" && !genericUsed </span><span class="cov0" title="0">{ + genericUsed = true + return modelGeneric, true + }</span> + } + <span class="cov7" title="162">return "", false</span> + } + + // Provider-specific + <span class="cov5" title="42">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + out.OpenAIBaseURL = s + any = true + }</span> + <span class="cov5" title="42">if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok </span><span class="cov2" title="5">{ + out.OpenAIModel = model + any = true + }</span> + <span class="cov5" title="42">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.OpenAITemperature = f + any = true + }</span> + + <span class="cov5" title="42">if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" </span><span class="cov0" title="0">{ + out.OpenRouterBaseURL = s + any = true + }</span> + <span class="cov5" title="42">if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok </span><span class="cov0" title="0">{ + out.OpenRouterModel = model + any = true + }</span> + <span class="cov5" title="42">if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + out.OpenRouterTemperature = f + any = true + }</span> + + <span class="cov5" title="42">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + out.OllamaBaseURL = s + any = true + }</span> + <span class="cov5" title="42">if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok </span><span class="cov1" title="1">{ + out.OllamaModel = model + any = true + }</span> + <span class="cov5" title="42">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.OllamaTemperature = f + any = true + }</span> + + <span class="cov5" title="42">if s := getenv("HEXAI_ANTHROPIC_BASE_URL"); s != "" </span><span class="cov0" title="0">{ + out.AnthropicBaseURL = s + any = true + }</span> + <span class="cov5" title="42">if model, ok := pickModel("anthropic", getenv("HEXAI_ANTHROPIC_MODEL")); ok </span><span class="cov0" title="0">{ + out.AnthropicModel = model + any = true + }</span> + <span class="cov5" title="42">if f, ok := parseFloatPtr("HEXAI_ANTHROPIC_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + out.AnthropicTemperature = f + any = true + }</span> + + // Per-surface overrides + <span class="cov5" title="42">buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) </span><span class="cov7" title="168">{ + model := getenv(modelKey) + tempPtr, tempSet := parseFloatPtr(tempKey) + provider := getenv(providerKey) + if model == "" && provider == "" && !tempSet </span><span class="cov7" title="162">{ + return nil, false + }</span> + <span class="cov3" title="6">entry := SurfaceConfig{Provider: provider, Model: model} + if tempSet </span><span class="cov3" title="6">{ + entry.Temperature = tempPtr + }</span> + <span class="cov3" title="6">return []SurfaceConfig{entry}, true</span> + } + <span class="cov5" title="42">if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok </span><span class="cov1" title="2">{ + out.CompletionConfigs = entries + any = true + }</span> + <span class="cov5" title="42">if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok </span><span class="cov1" title="1">{ + out.CodeActionConfigs = entries + any = true + }</span> + <span class="cov5" title="42">if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok </span><span class="cov1" title="1">{ + out.ChatConfigs = entries + any = true + }</span> + <span class="cov5" title="42">if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok </span><span class="cov1" title="2">{ + out.CLIConfigs = entries + any = true + }</span> + + // Ignore settings (bool: "true"/"1" or "false"/"0") + <span class="cov5" title="42">if s := getenv("HEXAI_IGNORE_GITIGNORE"); s != "" </span><span class="cov1" title="1">{ + b := s == "true" || s == "1" + out.IgnoreGitignore = &b + any = true + }</span> + <span class="cov5" title="42">if s := getenv("HEXAI_IGNORE_EXTRA_PATTERNS"); s != "" </span><span class="cov1" title="1">{ + parts := strings.Split(s, ",") + out.IgnoreExtraPatterns = nil + for _, p := range parts </span><span class="cov1" title="2">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov1" title="2">{ + out.IgnoreExtraPatterns = append(out.IgnoreExtraPatterns, t) + }</span> + } + <span class="cov1" title="1">any = true</span> + } + <span class="cov5" title="42">if s := getenv("HEXAI_IGNORE_LSP_NOTIFY"); s != "" </span><span class="cov1" title="1">{ + b := s == "true" || s == "1" + out.IgnoreLSPNotify = &b + any = true + }</span> + + <span class="cov5" title="42">if !any </span><span class="cov5" title="31">{ + return nil + }</span> + <span class="cov3" title="11">return &out</span> +} +</pre> + + <pre class="file" id="file5" style="display: none">package editor + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Resolve returns the editor command from HEXAI_EDITOR or EDITOR. +func Resolve() (string, error) <span class="cov10" title="11">{ + ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) + if ed == "" </span><span class="cov5" title="3">{ + ed = strings.TrimSpace(os.Getenv("EDITOR")) + }</span> + <span class="cov10" title="11">if ed == "" </span><span class="cov0" title="0">{ + return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") + }</span> + <span class="cov10" title="11">return ed, nil</span> +} + +// RunEditor is the seam that invokes the editor on the given file path. +// Override in tests to avoid launching a real editor. +var RunEditor = func(editor, path string) error <span class="cov0" title="0">{ + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +}</span> + +// OpenTempAndEdit creates a temporary .md file, writes initial content if provided, +// opens it in the resolved editor, then reads the final content and removes the file. +// Returns the trimmed content. +func OpenTempAndEdit(initial []byte) (string, error) <span class="cov7" title="5">{ + ed, err := Resolve() + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + // Create temp file under system temp dir; ensure .md suffix + <span class="cov7" title="5">dir := os.TempDir() + f, err := os.CreateTemp(dir, "hexai-*.md") + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="5">path := f.Name() + defer func() </span><span class="cov7" title="5">{ _ = os.Remove(path) }</span>() + <span class="cov7" title="5">if len(initial) > 0 </span><span class="cov5" title="3">{ + if _, err := f.Write(initial); err != nil </span><span class="cov0" title="0">{ + _ = f.Close() + return "", err + }</span> + } + <span class="cov7" title="5">if err := f.Sync(); err != nil </span><span class="cov0" title="0">{ + _ = f.Close() + return "", err + }</span> + <span class="cov7" title="5">if err := f.Close(); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="5">if err := RunEditor(ed, path); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="5">b, err := os.ReadFile(filepath.Clean(path)) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="5">return strings.TrimSpace(string(b)), nil</span> +} +</pre> + + <pre class="file" id="file6" style="display: none">package hexaiaction + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/hexai/internal/tmux" + "golang.org/x/term" +) + +// Options configures the command-line orchestration for hexai-tmux-action. +type Options struct { + Infile string + Outfile string + UIChild bool + TmuxTarget string + TmuxSplit string // "v" or "h" + TmuxPercent int // 1-100 +} + +// RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux +// split-pane mode by default, or child mode when -ui-child is set. +func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov4" title="2">{ + if opts.UIChild </span><span class="cov1" title="1">{ + return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) + }</span> + // Always use tmux path + <span class="cov1" title="1">return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)</span> +} + +// seams for unit tests +var ( + isTTYFn = func(fd uintptr) bool <span class="cov0" title="0">{ return term.IsTerminal(int(fd)) }</span> + splitRunFn = tmux.SplitRun + osExecutableFn = os.Executable + runFn = Run +) + +// openIO returns readers/writers for infile/outfile flags with deferred closers. +func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) <span class="cov7" title="3">{ + in := io.Reader(os.Stdin) + out := io.Writer(os.Stdout) + closeIn := func() </span>{<span class="cov0" title="0">}</span> + <span class="cov7" title="3">closeOut := func() </span>{<span class="cov0" title="0">}</span> + <span class="cov7" title="3">if path := infile; path != "" </span><span class="cov7" title="3">{ + f, err := os.Open(path) + if err != nil </span><span class="cov0" title="0">{ + return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) + } + <span class="cov7" title="3">in = f + closeIn = func() </span><span class="cov7" title="3">{ _ = f.Close() }</span> + } + <span class="cov7" title="3">if path := outfile; path != "" </span><span class="cov7" title="3">{ + f, err := os.Create(path) + if err != nil </span><span class="cov0" title="0">{ + return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) + } + <span class="cov7" title="3">out = f + closeOut = func() </span><span class="cov7" title="3">{ _ = f.Close() }</span> + } + <span class="cov7" title="3">return in, out, closeIn, closeOut, nil</span> +} + +// runChild runs the interactive flow and writes the final output atomically when outfile is set. +func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error <span class="cov7" title="3">{ + if outfile == "" </span><span class="cov1" title="1">{ + // No atomic handoff needed; just run normally to provided stdout + var in io.Reader = os.Stdin + if infile != "" </span><span class="cov1" title="1">{ + f, err := os.Open(infile) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() + <span class="cov1" title="1">in = f</span> + } + <span class="cov1" title="1">return runFn(ctx, in, stdout, stderr)</span> + } + <span class="cov4" title="2">tmp := outfile + ".tmp" + in, out, closeIn, closeOut, err := openIO(infile, tmp) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="2">defer closeIn() + if err := runFn(ctx, in, out, stderr); err != nil </span><span class="cov0" title="0">{ + closeOut() + if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr) + }</span> + } else<span class="cov4" title="2"> { + closeOut() + }</span> + <span class="cov4" title="2">return os.Rename(tmp, outfile)</span> +} + +func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error <span class="cov8" title="4">{ + dir, err := os.MkdirTemp("", "hexai-tmux-action-") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="4">defer func() </span><span class="cov8" title="4">{ _ = os.RemoveAll(dir) }</span>() + <span class="cov8" title="4">inPath := filepath.Join(dir, "input.txt") + outPath := filepath.Join(dir, "reply.txt") + if err := persistStdin(inPath, stdin); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="4">exe, err := osExecutableFn() + if err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov7" title="3">argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} + if err := splitRunFn(opts, argv); err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov4" title="2">if err := waitForFile(outPath, 60*time.Second); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="2">return catFileTo(stdout, outPath)</span> +} + +func persistStdin(path string, stdin io.Reader) error <span class="cov10" title="5">{ + f, err := os.Create(path) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov10" title="5">defer func() </span><span class="cov10" title="5">{ _ = f.Close() }</span>() + <span class="cov10" title="5">if _, err := io.Copy(f, stdin); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov10" title="5">return f.Sync()</span> +} + +func waitForFile(path string, timeout time.Duration) error <span class="cov7" title="3">{ + deadline := time.Now().Add(timeout) + for </span><span class="cov8" title="4">{ + if _, err := os.Stat(path); err == nil </span><span class="cov4" title="2">{ + return nil + }</span> + <span class="cov4" title="2">if time.Now().After(deadline) </span><span class="cov1" title="1">{ + return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file") + }</span> + <span class="cov1" title="1">time.Sleep(200 * time.Millisecond)</span> + } +} + +func catFileTo(w io.Writer, path string) error <span class="cov4" title="2">{ + f, err := os.Open(path) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="2">defer func() </span><span class="cov4" title="2">{ _ = f.Close() }</span>() + <span class="cov4" title="2">_, err = io.Copy(w, f) + return err</span> +} + +// echoThrough no longer used in tmux-only flow, but kept for potential reuse. +func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error <span class="cov4" title="2">{ + in := stdin + out := stdout + if infile != "" </span><span class="cov1" title="1">{ + f, err := os.Open(infile) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() + <span class="cov1" title="1">in = f</span> + } + <span class="cov4" title="2">if outfile != "" </span><span class="cov1" title="1">{ + f, err := os.Create(outfile) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ _ = f.Close() }</span>() + <span class="cov1" title="1">out = f</span> + } + <span class="cov4" title="2">_, err := io.Copy(out, in) + return err</span> +} +</pre> + + <pre class="file" id="file7" style="display: none">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) <span class="cov7" title="5">{ + b, err := io.ReadAll(bufio.NewReader(r)) + if err != nil </span><span class="cov0" title="0">{ + return InputParts{}, err + }</span> + <span class="cov7" title="5">raw := strings.TrimSpace(string(b)) + if raw == "" </span><span class="cov0" title="0">{ + return InputParts{Selection: ""}, nil + }</span> + <span class="cov7" title="5">lines := strings.Split(raw, "\n") + // find a case-insensitive line equal to "diagnostics:" + diagsIdx := -1 + for i, ln := range lines </span><span class="cov8" title="6">{ + t := strings.TrimSpace(strings.ToLower(ln)) + if t == "diagnostics:" </span><span class="cov1" title="1">{ + diagsIdx = i + break</span> + } + } + <span class="cov7" title="5">if diagsIdx < 0 </span><span class="cov7" title="4">{ + return InputParts{Selection: raw}, nil + }</span> + // collect diagnostics until a blank line or EOF + <span class="cov1" title="1">diags := []string{} + i := diagsIdx + 1 + for ; i < len(lines); i++ </span><span class="cov5" title="3">{ + t := strings.TrimSpace(lines[i]) + if t == "" </span><span class="cov1" title="1">{ + i++ + break</span> + } + <span class="cov4" title="2">diags = append(diags, t)</span> + } + <span class="cov1" title="1">sel := strings.Join(lines[i:], "\n") + sel = strings.TrimSpace(sel) + return InputParts{Selection: sel, Diagnostics: diags}, nil</span> +} + +// 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) <span class="cov10" title="8">{ return textutil.InstructionFromSelection(sel) }</span> + +// findFirstInstructionInLine follows the same precedence as LSP: +// - ;text; (strict) +// - /* text */ (single-line) +// - <!-- text --> (single-line) +// - // text +// - # text +// - -- text +// helpers moved to textutil +</pre> + + <pre class="file" id="file8" style="display: none">package hexaiaction + +import ( + "context" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/textutil" + "codeberg.org/snonux/hexai/internal/tmux" +) + +// Render performs simple {{var}} replacement like LSP. +func Render(t string, vars map[string]string) string <span class="cov7" title="18">{ return textutil.RenderTemplate(t, vars) }</span> + +// StripFences removes surrounding markdown code fences. +func StripFences(s string) string <span class="cov7" title="19">{ return textutil.StripCodeFences(s) }</span> + +type chatDoer interface { + Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) + DefaultModel() string +} + +type providerNamer interface{ Name() string } + +type requestArgs struct { + model string + options []llm.RequestOption +} + +func providerOf(c any) string <span class="cov10" title="54">{ + if n, ok := c.(providerNamer); ok </span><span class="cov5" title="6">{ + return n.Name() + }</span> + <span class="cov9" title="48">return "llm"</span> +} + +func canonicalProvider(name string) string <span class="cov8" title="24">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov7" title="20">{ + return "openai" + }</span> + <span class="cov4" title="4">return p</span> +} + +func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov9" title="41">{ + switch provider </span>{ + case "ollama":<span class="cov0" title="0"> + return cfg.OllamaModel</span> + case "anthropic":<span class="cov0" title="0"> + return cfg.AnthropicModel</span> + default:<span class="cov9" title="41"> + return cfg.OpenAIModel</span> + } +} + +func selectActionTemperature(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov7" title="22">{ + if entry.Temperature != nil </span><span class="cov1" title="1">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="21">if cfg.CodingTemperature != nil </span><span class="cov7" title="17">{ + temp := *cfg.CodingTemperature + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov1" title="1">{ + temp = 1.0 + }</span> + <span class="cov7" title="17">return temp, true</span> + } + <span class="cov4" title="4">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ + return 1.0, true + }</span> + <span class="cov4" title="4">return 0, false</span> +} + +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="7">{ + sys := cfg.PromptCodeActionRewriteSystem + user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) +}</span> + +func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) <span class="cov2" title="2">{ + var b strings.Builder + for i, d := range diags </span><span class="cov3" title="3">{ + if strings.TrimSpace(d) == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov3" title="3">b.WriteString(strings.TrimSpace(d)) + if i < len(diags)-1 </span><span class="cov1" title="1">{ + b.WriteString("\n") + }</span> + } + <span class="cov2" title="2">sys := cfg.PromptCodeActionDiagnosticsSystem + user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> +} + +func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov2" title="2">{ + sys := cfg.PromptCodeActionDocumentSystem + user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) +}</span> + +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov2" title="2">{ + sys := cfg.PromptCodeActionSimplifySystem + user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) +}</span> + +func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) <span class="cov2" title="2">{ + sys := cfg.PromptCodeActionGoTestSystem + user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) +}</span> + +func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) <span class="cov4" title="5">{ + // If user template is provided, prefer it and optional system + if strings.TrimSpace(ca.User) != "" </span><span class="cov2" title="2">{ + sys := cfg.PromptCodeActionRewriteSystem + if strings.TrimSpace(ca.System) != "" </span><span class="cov0" title="0">{ + sys = ca.System + }</span> + // Currently only selection is available in tmux path; diagnostics list not wired + <span class="cov2" title="2">user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> + } + // Else, use fixed instruction through rewrite template + <span class="cov3" title="3">return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection)</span> +} + +func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) <span class="cov1" title="1">{ + msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + txt, err := client.Chat(ctx, msgs) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov1" title="1">out := strings.TrimSpace(StripFences(txt)) + // Contribute to global stats and update tmux status + sent := 0 + for _, m := range msgs </span><span class="cov2" title="2">{ + sent += len(m.Content) + }</span> + <span class="cov1" title="1">recv := len(out) + _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov1" title="1">{ + minsWin := snap.Window.Minutes() + if minsWin <= 0 </span><span class="cov0" title="0">{ + minsWin = 0.001 + }</span> + <span class="cov1" title="1">scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov1" title="1">{ + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 </span><span class="cov1" title="1">{ + scopeReqs = mc.Reqs + }</span> + } + <span class="cov1" title="1">scopeRPM := float64(scopeReqs) / minsWin + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window))</span> + } + <span class="cov1" title="1">return out, nil</span> +} + +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) <span class="cov7" title="17">{ + msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + txt, err := client.Chat(ctx, msgs, req.options...) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="17">out := strings.TrimSpace(StripFences(txt)) + // Contribute to global stats and update tmux status + sent := 0 + for _, m := range msgs </span><span class="cov8" title="34">{ + sent += len(m.Content) + }</span> + <span class="cov7" title="17">recv := len(out) + model := strings.TrimSpace(req.model) + if model == "" </span><span class="cov7" title="17">{ + model = client.DefaultModel() + }</span> + <span class="cov7" title="17">_ = stats.Update(ctx, providerOf(client), model, sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil </span><span class="cov7" title="17">{ + minsWin := snap.Window.Minutes() + if minsWin <= 0 </span><span class="cov0" title="0">{ + minsWin = 0.001 + }</span> + <span class="cov7" title="17">scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok </span><span class="cov7" title="17">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="17">{ + scopeReqs = mc.Reqs + }</span> + } + <span class="cov7" title="17">scopeRPM := float64(scopeReqs) / minsWin + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), model, scopeRPM, scopeReqs, snap.Window))</span> + } + <span class="cov7" title="17">return out, nil</span> +} + +// reqOptsFrom builds LLM request options similar to LSP behavior. +func reqOptsFrom(cfg appconfig.App) requestArgs <span class="cov7" title="22">{ + opts := make([]llm.RequestOption, 0, 3) + if cfg.MaxTokens > 0 </span><span class="cov7" title="17">{ + opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) + }</span> + <span class="cov7" title="22">provider := canonicalProvider(cfg.Provider) + entries := cfg.CodeActionConfigs + if len(entries) == 0 </span><span class="cov7" title="21">{ + entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider, Model: strings.TrimSpace(defaultModelForProvider(cfg, provider))}} + }</span> + <span class="cov7" title="22">primary := entries[0] + if strings.TrimSpace(primary.Provider) != "" </span><span class="cov2" title="2">{ + provider = canonicalProvider(primary.Provider) + }</span> + <span class="cov7" title="22">model := strings.TrimSpace(primary.Model) + if model == "" </span><span class="cov7" title="20">{ + model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) + }</span> + <span class="cov7" title="22">if strings.TrimSpace(primary.Model) != "" </span><span class="cov2" title="2">{ + opts = append(opts, llm.WithModel(strings.TrimSpace(primary.Model))) + }</span> + <span class="cov7" title="22">if temp, ok := selectActionTemperature(cfg, provider, primary, model); ok </span><span class="cov7" title="18">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov7" title="22">return requestArgs{model: model, options: opts}</span> +} + +// Timeout helpers to mirror LSP behavior. +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov6" title="10">{ + return context.WithTimeout(parent, 20*time.Second) +}</span> + +func timeout8s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov1" title="1">{ + return context.WithTimeout(parent, 18*time.Second) +}</span> +</pre> + + <pre class="file" id="file9" style="display: none">package hexaiaction + +import ( + "context" + "fmt" + "io" + "log" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llmutils" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/tmux" +) + +// Run executes the hexai-tmux-action command flow. +// seams for testability +var ( + chooseActionFn = RunTUI + newClientFromApp = llmutils.NewClientFromApp +) + +type configPathKey struct{} + +// selectedCustom carries the chosen custom action (if any) from the TUI submenu +// to the executor. Cleared after use. +var selectedCustom *appconfig.CustomAction + +func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov6" title="4">{ + logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) + if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="4">{ + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + }</span> + <span class="cov6" title="4">if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + // Enable custom action submenu with configurable hotkey + <span class="cov6" title="4">if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ + chooseActionFn = func() (ActionKind, error) </span><span class="cov0" title="0">{ return RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey) }</span> + } + <span class="cov6" title="4">if len(cfg.CodeActionConfigs) > 0 </span><span class="cov0" title="0">{ + if provider := strings.TrimSpace(cfg.CodeActionConfigs[0].Provider); provider != "" </span><span class="cov0" title="0">{ + cfg.Provider = provider + }</span> + } + <span class="cov6" title="4">cli, err := newClientFromApp(cfg) + if err != nil </span><span class="cov1" title="1">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov5" title="3">primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) + if primaryModel == "" </span><span class="cov5" title="3">{ + primaryModel = cli.DefaultModel() + }</span> + <span class="cov5" title="3">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) + var client chatDoer = cli + parts, err := ParseInput(stdin) + if err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset) + return err + }</span> + <span class="cov5" title="3">if strings.TrimSpace(parts.Selection) == "" </span><span class="cov0" title="0">{ + return fmt.Errorf("hexai-tmux-action: no input provided on stdin") + }</span> + <span class="cov5" title="3">kind, err := chooseActionFn() + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="3">out, err := executeAction(ctx, kind, parts, cfg, client, stderr) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="3">_, _ = io.WriteString(stdout, out) + return nil</span> +} + +// WithConfigPath attaches a config path override to the context for Run/RunCommand. +func WithConfigPath(ctx context.Context, path string) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <span class="cov0" title="0">return context.WithValue(ctx, configPathKey{}, strings.TrimSpace(path))</span> +} + +func configPathFromContext(ctx context.Context) string <span class="cov6" title="4">{ + if ctx == nil </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov6" title="4">if v, ok := ctx.Value(configPathKey{}).(string); ok </span><span class="cov0" title="0">{ + return strings.TrimSpace(v) + }</span> + <span class="cov6" title="4">return ""</span> +} + +func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov9" title="10">{ + switch kind </span>{ + case ActionSkip:<span class="cov3" title="2"> + return parts.Selection, nil</span> + case ActionRewrite:<span class="cov3" title="2"> + return handleRewriteAction(ctx, parts, cfg, client, stderr)</span> + case ActionDiagnostics:<span class="cov0" title="0"> + return handleDiagnosticsAction(ctx, parts, cfg, client)</span> + case ActionDocument:<span class="cov1" title="1"> + return handleDocumentAction(ctx, parts, cfg, client)</span> + case ActionGoTest:<span class="cov1" title="1"> + return handleGoTestAction(ctx, parts, cfg, client)</span> + case ActionSimplify:<span class="cov0" title="0"> + return handleSimplifyAction(ctx, parts, cfg, client)</span> + case ActionCustom:<span class="cov5" title="3"> + return handleCustomAction(ctx, parts, cfg, client)</span> + case ActionCustomPrompt:<span class="cov1" title="1"> + return handleCustomPromptAction(ctx, parts, cfg, client, stderr)</span> + default:<span class="cov0" title="0"> + return parts.Selection, nil</span> + } +} + +func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov3" title="2">{ + instr, cleaned := ExtractInstruction(parts.Selection) + if strings.TrimSpace(instr) == "" </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) + return parts.Selection, nil + }</span> + <span class="cov3" title="2">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov3" title="2">{ + return runRewrite(cctx, cfg, client, instr, cleaned) + }</span>) +} + +func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) + }</span>) +} + +func handleDocumentAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + return runDocument(cctx, cfg, client, parts.Selection) + }</span>) +} + +func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + return runGoTest(cctx, cfg, client, parts.Selection) + }</span>) +} + +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov1" title="1">{ + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + return runSimplify(cctx, cfg, client, parts.Selection) + }</span>) +} + +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) <span class="cov6" title="4">{ + if selectedCustom == nil </span><span class="cov0" title="0">{ + return parts.Selection, nil + }</span> + <span class="cov6" title="4">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov6" title="4">{ + out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) + selectedCustom = nil + return out, err + }</span>) +} + +func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov1" title="1">{ + prompt, err := editor.OpenTempAndEdit(nil) + if err != nil || strings.TrimSpace(prompt) == "" </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) + return parts.Selection, nil + }</span> + <span class="cov1" title="1">return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) </span><span class="cov1" title="1">{ + return runRewrite(cctx, cfg, client, prompt, parts.Selection) + }</span>) +} + +func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) <span class="cov10" title="11">{ + innerCtx, cancel := timeout(ctx) + defer cancel() + return fn(innerCtx) +}</span> + +// client construction is shared via internal/llmutils +</pre> + + <pre class="file" id="file10" style="display: none">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 <span class="cov1" title="1">{ return i.title }</span> +func (i item) Description() string <span class="cov1" title="1">{ return i.desc }</span> +func (i item) FilterValue() string <span class="cov6" title="3">{ return i.title }</span> + +type model struct { + list list.Model + chosen ActionKind + done bool +} + +func newModel() model <span class="cov10" title="6">{ + items := []list.Item{ + item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, + item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, + item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'}, + item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'}, + item{title: "Custom prompt", desc: "", kind: ActionCustomPrompt, hotkey: 'p'}, + item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, + } + l := list.New(items, oneLineDelegate{}, 0, 0) + l.SetShowTitle(false) + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + return model{list: l} +}</span> + +func (m model) Init() tea.Cmd <span class="cov1" title="1">{ return nil }</span> + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov1" title="1">{ + switch msg := msg.(type) </span>{ + case tea.KeyMsg:<span class="cov0" title="0"> + return handleKey(m, msg)</span> + case tea.WindowSizeMsg:<span class="cov1" title="1"> + m.list.SetSize(msg.Width, msg.Height)</span> + } + <span class="cov1" title="1">var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd</span> +} + +func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov6" title="3">{ + raw := msg.String() + low := strings.ToLower(raw) + switch low </span>{ + case "esc", "q":<span class="cov1" title="1"> + // Treat ESC and q as Skip/quit + m.chosen = ActionSkip + m.done = true + return m, tea.Quit</span> + case "enter":<span class="cov0" title="0"> + if it, ok := m.list.SelectedItem().(item); ok </span><span class="cov0" title="0">{ + m.chosen = it.kind + m.done = true + return m, tea.Quit + }</span> + case "j", "down":<span class="cov0" title="0"> + m.list.CursorDown()</span> + case "k", "up":<span class="cov0" title="0"> + m.list.CursorUp()</span> + case "g", "home":<span class="cov1" title="1"> + m.list.Select(0)</span> + case "end":<span class="cov0" title="0"> + if n := len(m.list.Items()); n > 0 </span><span class="cov0" title="0">{ + m.list.Select(n - 1) + }</span> + case "s", "r", "c", "t", "i", "p":<span class="cov1" title="1"> + items := m.list.Items() + for i := 0; i < len(items); i++ </span><span class="cov1" title="1">{ + if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low </span><span class="cov1" title="1">{ + m.list.Select(i) + m.chosen = it.kind + m.done = true + return m, tea.Quit + }</span> + } + } + <span class="cov1" title="1">if raw == "G" </span><span class="cov1" title="1">{ // Shift+G jumps to end + if n := len(m.list.Items()); n > 0 </span><span class="cov1" title="1">{ + m.list.Select(n - 1) + }</span> + } + <span class="cov1" title="1">return m, nil</span> +} + +func (m model) View() string <span class="cov1" title="1">{ + if m.done </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov1" title="1">return m.list.View()</span> +} + +// RunTUI returns the chosen ActionKind. +func RunTUI() (ActionKind, error) <span class="cov0" title="0">{ + p := tea.NewProgram(newModel()) + md, err := p.Run() + if err != nil </span><span class="cov0" title="0">{ + return ActionSkip, err + }</span> + <span class="cov0" title="0">if m, ok := md.(model); ok </span><span class="cov0" title="0">{ + if m.chosen == "" </span><span class="cov0" title="0">{ + return ActionSkip, nil + }</span> + <span class="cov0" title="0">return m.chosen, nil</span> + } + <span class="cov0" title="0">return ActionSkip, fmt.Errorf("unexpected model type")</span> +} +</pre> + + <pre class="file" id="file11" style="display: none">package hexaiaction + +import ( + "unicode/utf8" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// RunTUIWithCustom shows the main menu plus a configurable "Custom actions…" item. +// If the user selects that item, it shows a submenu listing user-defined custom actions. +// On picking one, it sets selectedCustom and returns ActionCustom. +func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, error) <span class="cov1" title="1">{ + // When no customs, fall back to default menu + if len(customs) == 0 </span><span class="cov0" title="0">{ + return RunTUI() + }</span> + // Build main menu with an extra entry + <span class="cov1" title="1">hk := 'a' + if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 </span><span class="cov1" title="1">{ + hk = r + }</span> + // Create a model with default items plus Custom actions… + <span class="cov1" title="1">m := newModel() + items := m.list.Items() + items = append(items, item{title: "Custom actions…", desc: "", kind: ActionCustom, hotkey: hk}) + m.list.SetItems(items) + // Run main menu + p := teaNewProgram(m) + md, err := p.Run() + if err != nil </span><span class="cov0" title="0">{ + return ActionSkip, err + }</span> + <span class="cov1" title="1">if mm, ok := md.(model); ok </span><span class="cov1" title="1">{ + // If user chose built-in items (including Custom prompt), return immediately. + if mm.chosen != ActionCustom </span><span class="cov0" title="0">{ + return mm.chosen, nil + }</span> + } + // Custom submenu: list each action; select one maps to ActionCustom and sets global + <span class="cov1" title="1">sub := newModel() + subItems := make([]list.Item, 0, len(customs)) + for _, ca := range customs </span><span class="cov10" title="2">{ + r := rune(0) + if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 </span><span class="cov10" title="2">{ + r = rr + }</span> + <span class="cov10" title="2">subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r})</span> + } + <span class="cov1" title="1">sub.list.SetItems(subItems) + sp := teaNewProgram(sub) + smd, err := sp.Run() + if err != nil </span><span class="cov0" title="0">{ + return ActionSkip, err + }</span> + <span class="cov1" title="1">if sm, ok := smd.(model); ok </span><span class="cov1" title="1">{ + if it, ok := sm.list.SelectedItem().(item); ok </span><span class="cov1" title="1">{ + // Map by title + for i := range customs </span><span class="cov1" title="1">{ + if customs[i].Title == it.title </span><span class="cov1" title="1">{ + c := customs[i] + selectedCustom = &c + return ActionCustom, nil + }</span> + } + } + } + <span class="cov0" title="0">return ActionSkip, nil</span> +} + +// teaNewProgram is a tiny seam for tests to stub bubbletea program creation. +var teaNewProgram = func(m model) teaProgram <span class="cov0" title="0">{ return tea.NewProgram(m) }</span> + +// teaProgram is the subset of bubbletea.Program we need; enables testing seam. +type teaProgram interface{ Run() (tea.Model, error) } +</pre> + + <pre class="file" id="file12" style="display: none">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 <span class="cov8" title="28">{ return 1 }</span> +func (oneLineDelegate) Spacing() int <span class="cov10" title="50">{ return 0 }</span> +func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd <span class="cov1" title="1">{ return nil }</span> +func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) <span class="cov2" title="2">{ + title := listItem.FilterValue() + hk := '?' + if it, ok := listItem.(item); ok </span><span class="cov2" title="2">{ + hk = it.hotkey + }</span> + <span class="cov2" title="2">hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) + cursor := " " + if index == m.Index() </span><span class="cov2" title="2">{ + cursor = cursorStyle.Render("> ") + }</span> + <span class="cov2" title="2">_, _ = fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span> +} +</pre> + + <pre class="file" id="file13" style="display: none">// 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 ( + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llmutils" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/tmux" + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +type requestArgs struct { + model string + options []llm.RequestOption +} + +type cliJob struct { + index int + provider string + entry appconfig.SurfaceConfig + client llm.Client + req requestArgs +} + +type columnPrinter struct { + mu sync.Mutex + stdout io.Writer + columns int + colWidth int + partial []string + providers []string + models []string +} + +type columnWriter struct { + printer *columnPrinter + index int +} + +type ( + selectionContextKey struct{} + configPathContextKey struct{} +) + +func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) <span class="cov7" title="6">{ + entries := cfg.CLIConfigs + if len(entries) == 0 </span><span class="cov6" title="5">{ + entries = []appconfig.SurfaceConfig{{}} + }</span> + <span class="cov7" title="6">jobs := make([]cliJob, 0, len(entries)) + for i, raw := range entries </span><span class="cov7" title="7">{ + entry := appconfig.SurfaceConfig{Provider: strings.TrimSpace(raw.Provider), Model: strings.TrimSpace(raw.Model), Temperature: raw.Temperature} + provider := entry.Provider + if provider == "" </span><span class="cov6" title="5">{ + provider = cfg.Provider + }</span> + <span class="cov7" title="7">provider = canonicalProvider(provider) + derived := cfg + derived.Provider = provider + switch provider </span>{ + case "openai":<span class="cov7" title="6"> + if entry.Model != "" </span><span class="cov1" title="1">{ + derived.OpenAIModel = entry.Model + }</span> + case "ollama":<span class="cov0" title="0"> + if entry.Model != "" </span><span class="cov0" title="0">{ + derived.OllamaModel = entry.Model + }</span> + } + <span class="cov7" title="7">client, err := newClientFromApp(derived) + if err != nil </span><span class="cov1" title="1">{ + return nil, err + }</span> + <span class="cov7" title="6">req := buildCLIRequest(entry, provider, cfg, client) + if strings.TrimSpace(req.model) == "" </span><span class="cov0" title="0">{ + req.model = strings.TrimSpace(client.DefaultModel()) + }</span> + <span class="cov7" title="6">jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, client: client, req: req})</span> + } + <span class="cov6" title="5">return jobs, nil</span> +} + +func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs <span class="cov8" title="8">{ + opts := make([]llm.RequestOption, 0, 2) + if cfg.MaxTokens > 0 </span><span class="cov5" title="4">{ + opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) + }</span> + <span class="cov8" title="8">model := strings.TrimSpace(entry.Model) + if model == "" </span><span class="cov6" title="5">{ + if client != nil </span><span class="cov6" title="5">{ + model = strings.TrimSpace(client.DefaultModel()) + }</span> + <span class="cov6" title="5">if model == "" </span><span class="cov0" title="0">{ + model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) + }</span> + } + <span class="cov8" title="8">if entry.Model != "" </span><span class="cov4" title="3">{ + opts = append(opts, llm.WithModel(entry.Model)) + }</span> + <span class="cov8" title="8">if temp, ok := cliTemperatureFromEntry(cfg, provider, entry, model); ok </span><span class="cov7" title="6">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov8" title="8">return requestArgs{model: model, options: opts}</span> +} + +func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) <span class="cov8" title="8">{ + if entry.Temperature != nil </span><span class="cov1" title="1">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="7">if cfg.CodingTemperature != nil </span><span class="cov6" title="5">{ + temp := *cfg.CodingTemperature + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 </span><span class="cov3" title="2">{ + temp = 1.0 + }</span> + <span class="cov6" title="5">return temp, true</span> + } + <span class="cov3" title="2">if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") </span><span class="cov0" title="0">{ + return 1.0, true + }</span> + <span class="cov3" title="2">return 0, false</span> +} + +func canonicalProvider(name string) string <span class="cov7" title="7">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov4" title="3">{ + return "openai" + }</span> + <span class="cov5" title="4">return p</span> +} + +func defaultModelForProvider(cfg appconfig.App, provider string) string <span class="cov0" title="0">{ + switch provider </span>{ + case "ollama":<span class="cov0" title="0"> + return cfg.OllamaModel</span> + case "anthropic":<span class="cov0" title="0"> + return cfg.AnthropicModel</span> + default:<span class="cov0" title="0"> + return cfg.OpenAIModel</span> + } +} + +// 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 <span class="cov6" title="5">{ + // Load configuration with a logger so file-based config is respected. + logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) + configPath := configPathFromContext(ctx) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) + if cfg.StatsWindowMinutes > 0 </span><span class="cov6" title="5">{ + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + }</span> + <span class="cov6" title="5">jobs, err := buildCLIJobs(cfg) + if err != nil </span><span class="cov1" title="1">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov5" title="4">if selected := selectionFromContext(ctx); len(selected) > 0 </span><span class="cov0" title="0">{ + jobs, err = filterJobsBySelection(jobs, selected) + if err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + } + <span class="cov5" title="4">if len(jobs) == 0 </span><span class="cov0" title="0">{ + return fmt.Errorf("hexai: no CLI providers configured") + }</span> + // Prefer piped stdin when present; only open the editor when there are no args + // and no stdin content available. + <span class="cov5" title="4">input, rerr := readInput(stdin, args) + if rerr != nil && len(args) == 0 </span><span class="cov1" title="1">{ + if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" </span><span class="cov1" title="1">{ + args = []string{prompt} + input, rerr = readInput(stdin, args) + }</span> + } + <span class="cov5" title="4">if rerr != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) + return rerr + }</span> + <span class="cov5" title="4">msgs := buildMessagesFromConfig(cfg, input) + if err := runCLIJobs(ctx, jobs, msgs, input, stdout, stderr); err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov5" title="4">return nil</span> +} + +// 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 <span class="cov1" title="1">{ + input, err := readInput(stdin, args) + if err != nil </span><span class="cov0" title="0">{ + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + }</span> + <span class="cov1" title="1">req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} + printProviderInfo(stderr, client, req.model) + msgs := buildMessages(input) + if err := runChat(ctx, client, req, msgs, input, stdout, stderr); err != nil </span><span class="cov1" title="1">{ + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov0" title="0">return nil</span> +} + +type cliJobResult struct { + provider string + model string + output string + summary string + err error +} + +func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error <span class="cov5" title="4">{ + results := make([]*cliJobResult, len(jobs)) + var wg sync.WaitGroup + var printer *columnPrinter + if len(jobs) > 0 </span><span class="cov5" title="4">{ + printer = newColumnPrinter(stdout, jobs) + printer.PrintHeader() + }</span> + <span class="cov5" title="4">for _, job := range jobs </span><span class="cov5" title="4">{ + job := job + wg.Add(1) + printProviderInfo(stderr, job.client, job.req.model) + go func() </span><span class="cov5" title="4">{ + defer wg.Done() + var errBuf bytes.Buffer + var outBuf bytes.Buffer + jobMsgs := make([]llm.Message, len(msgs)) + copy(jobMsgs, msgs) + writer := io.Writer(&outBuf) + if printer != nil </span><span class="cov5" title="4">{ + writer = printer.Writer(job.index) + }</span> + <span class="cov5" title="4">err := runChat(ctx, job.client, job.req, jobMsgs, input, writer, &errBuf) + if printer != nil </span><span class="cov5" title="4">{ + printer.Flush(job.index) + }</span> + <span class="cov5" title="4">results[job.index] = &cliJobResult{ + provider: job.client.Name(), + model: job.req.model, + output: outBuf.String(), + summary: errBuf.String(), + err: err, + }</span> + }() + } + <span class="cov5" title="4">wg.Wait() + var firstErr error + if printer == nil </span><span class="cov0" title="0">{ + printed := false + for _, res := range results </span><span class="cov0" title="0">{ + if res == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">if printed </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, "\n"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + <span class="cov0" title="0">heading := fmt.Sprintf("=== %s:%s ===\n", res.provider, res.model) + if _, err := io.WriteString(stdout, heading); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">if res.output != "" </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, res.output); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">if !strings.HasSuffix(res.output, "\n") </span><span class="cov0" title="0">{ + if _, err := io.WriteString(stdout, "\n"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + } + <span class="cov0" title="0">printed = true</span> + } + } + <span class="cov5" title="4">for _, res := range results </span><span class="cov5" title="4">{ + if res == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov5" title="4">if res.summary != "" </span><span class="cov5" title="4">{ + summary := strings.TrimLeft(res.summary, "\n") + if summary != "" </span><span class="cov5" title="4">{ + if _, err := io.WriteString(stderr, summary); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + } + <span class="cov5" title="4">if res.err != nil </span><span class="cov0" title="0">{ + if _, err := fmt.Fprintf(stderr, logging.AnsiBase+"hexai: provider=%s model=%s error: %v"+logging.AnsiReset+"\n", res.provider, res.model, res.err); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + <span class="cov5" title="4">if firstErr == nil && res.err != nil </span><span class="cov0" title="0">{ + firstErr = res.err + }</span> + } + <span class="cov5" title="4">return firstErr</span> +} + +func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter <span class="cov5" title="4">{ + cols := len(jobs) + width := detectTerminalWidth(stdout) + if width <= 0 </span><span class="cov5" title="4">{ + width = 100 + }</span> + <span class="cov5" title="4">sepWidth := (cols - 1) * 3 + colWidth := (width - sepWidth) / cols + if colWidth < 20 </span><span class="cov0" title="0">{ + colWidth = 20 + }</span> + <span class="cov5" title="4">providers := make([]string, cols) + models := make([]string, cols) + for _, job := range jobs </span><span class="cov5" title="4">{ + providers[job.index] = job.client.Name() + models[job.index] = job.req.model + }</span> + <span class="cov5" title="4">return &columnPrinter{ + stdout: stdout, + columns: cols, + colWidth: colWidth, + partial: make([]string, cols), + providers: providers, + models: models, + }</span> +} + +func detectTerminalWidth(w io.Writer) int <span class="cov5" title="4">{ + type fder interface{ Fd() uintptr } + if f, ok := w.(*os.File); ok </span><span class="cov0" title="0">{ + if width, _, err := term.GetSize(int(f.Fd())); err == nil </span><span class="cov0" title="0">{ + return width + }</span> + } + <span class="cov5" title="4">if f, ok := w.(fder); ok </span><span class="cov0" title="0">{ + if width, _, err := term.GetSize(int(f.Fd())); err == nil </span><span class="cov0" title="0">{ + return width + }</span> + } + <span class="cov5" title="4">return 0</span> +} + +func (cp *columnPrinter) Writer(idx int) io.Writer <span class="cov5" title="4">{ + return columnWriter{printer: cp, index: idx} +}</span> + +func (cp *columnPrinter) PrintHeader() <span class="cov5" title="4">{ + cp.mu.Lock() + defer cp.mu.Unlock() + combo := make([]string, cp.columns) + for i := 0; i < cp.columns; i++ </span><span class="cov5" title="4">{ + provider := strings.TrimSpace(cp.providers[i]) + model := strings.TrimSpace(cp.models[i]) + switch </span>{ + case provider != "" && model != "":<span class="cov5" title="4"> + combo[i] = provider + ":" + model</span> + case provider != "":<span class="cov0" title="0"> + combo[i] = provider</span> + case model != "":<span class="cov0" title="0"> + combo[i] = model</span> + default:<span class="cov0" title="0"> + combo[i] = ""</span> + } + } + <span class="cov5" title="4">cp.writeLine(combo) + divider := make([]string, cp.columns) + line := strings.Repeat("─", cp.colWidth) + for i := range divider </span><span class="cov5" title="4">{ + divider[i] = line + }</span> + <span class="cov5" title="4">cp.writeLine(divider)</span> +} + +func (cp *columnPrinter) Flush(idx int) <span class="cov5" title="4">{ + cp.mu.Lock() + defer cp.mu.Unlock() + if idx < 0 || idx >= len(cp.partial) </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov5" title="4">if cp.partial[idx] == "" </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov5" title="4">cp.emitJobLine(idx, cp.partial[idx]) + cp.partial[idx] = ""</span> +} + +func (w columnWriter) Write(p []byte) (int, error) <span class="cov5" title="4">{ + return w.printer.write(w.index, string(p)) +}</span> + +func (cp *columnPrinter) write(idx int, data string) (int, error) <span class="cov5" title="4">{ + cp.mu.Lock() + defer cp.mu.Unlock() + if idx < 0 || idx >= len(cp.partial) </span><span class="cov0" title="0">{ + return len(data), nil + }</span> + <span class="cov5" title="4">data = strings.ReplaceAll(data, "\r", "") + cp.partial[idx] += data + for strings.Contains(cp.partial[idx], "\n") </span><span class="cov0" title="0">{ + line, rest, _ := strings.Cut(cp.partial[idx], "\n") + cp.partial[idx] = rest + cp.emitJobLine(idx, line) + }</span> + <span class="cov5" title="4">return len(data), nil</span> +} + +func (cp *columnPrinter) emitJobLine(idx int, line string) <span class="cov5" title="4">{ + segments := cp.wrap(line) + for _, seg := range segments </span><span class="cov5" title="4">{ + cells := make([]string, cp.columns) + if idx >= 0 && idx < len(cells) </span><span class="cov5" title="4">{ + cells[idx] = seg + }</span> + <span class="cov5" title="4">cp.writeLine(cells)</span> + } +} + +func (cp *columnPrinter) wrap(text string) []string <span class="cov5" title="4">{ + text = strings.ReplaceAll(text, "\t", " ") + if runewidth.StringWidth(text) <= cp.colWidth </span><span class="cov5" title="4">{ + return []string{text} + }</span> + <span class="cov0" title="0">var lines []string + var current strings.Builder + width := 0 + for _, r := range text </span><span class="cov0" title="0">{ + rw := runewidth.RuneWidth(r) + if width+rw > cp.colWidth && current.Len() > 0 </span><span class="cov0" title="0">{ + lines = append(lines, current.String()) + current.Reset() + width = 0 + }</span> + <span class="cov0" title="0">current.WriteRune(r) + width += rw</span> + } + <span class="cov0" title="0">if current.Len() > 0 </span><span class="cov0" title="0">{ + lines = append(lines, current.String()) + }</span> + <span class="cov0" title="0">if len(lines) == 0 </span><span class="cov0" title="0">{ + lines = append(lines, "") + }</span> + <span class="cov0" title="0">return lines</span> +} + +func (cp *columnPrinter) writeLine(cells []string) <span class="cov9" title="12">{ + if len(cells) < cp.columns </span><span class="cov0" title="0">{ + extra := make([]string, cp.columns-len(cells)) + cells = append(cells, extra...) + }</span> + <span class="cov9" title="12">var builder strings.Builder + for i := 0; i < cp.columns; i++ </span><span class="cov9" title="12">{ + cell := cells[i] + width := runewidth.StringWidth(cell) + if width > cp.colWidth </span><span class="cov0" title="0">{ + cell = runewidth.Truncate(cell, cp.colWidth, "…") + width = runewidth.StringWidth(cell) + }</span> + <span class="cov9" title="12">builder.WriteString(cell) + if pad := cp.colWidth - width; pad > 0 </span><span class="cov8" title="8">{ + builder.WriteString(strings.Repeat(" ", pad)) + }</span> + <span class="cov9" title="12">if i != cp.columns-1 </span><span class="cov0" title="0">{ + builder.WriteString(" │ ") + }</span> + } + <span class="cov9" title="12">builder.WriteByte('\n') + _, _ = cp.stdout.Write([]byte(builder.String()))</span> +} + +// WithCLISelection injects provider indices into the context so Run only executes those jobs. +func WithCLISelection(ctx context.Context, indices []int) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <span class="cov0" title="0">cpy := make([]int, len(indices)) + copy(cpy, indices) + return context.WithValue(ctx, selectionContextKey{}, cpy)</span> +} + +// WithCLIConfigPath returns a context that carries the config file path override. +func WithCLIConfigPath(ctx context.Context, path string) context.Context <span class="cov0" title="0">{ + if ctx == nil </span><span class="cov0" title="0">{ + ctx = context.Background() + }</span> + <span class="cov0" title="0">return context.WithValue(ctx, configPathContextKey{}, strings.TrimSpace(path))</span> +} + +func configPathFromContext(ctx context.Context) string <span class="cov6" title="5">{ + if ctx == nil </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov6" title="5">if v, ok := ctx.Value(configPathContextKey{}).(string); ok </span><span class="cov0" title="0">{ + return strings.TrimSpace(v) + }</span> + <span class="cov6" title="5">return ""</span> +} + +func selectionFromContext(ctx context.Context) []int <span class="cov5" title="4">{ + if ctx == nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov5" title="4">if v, ok := ctx.Value(selectionContextKey{}).([]int); ok </span><span class="cov0" title="0">{ + cpy := make([]int, len(v)) + copy(cpy, v) + return cpy + }</span> + <span class="cov5" title="4">return nil</span> +} + +func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) <span class="cov3" title="2">{ + if len(indices) == 0 </span><span class="cov0" title="0">{ + return jobs, nil + }</span> + <span class="cov3" title="2">filtered := make([]cliJob, 0, len(indices)) + seen := make(map[int]struct{}, len(indices)) + for _, idx := range indices </span><span class="cov4" title="3">{ + if idx < 0 || idx >= len(jobs) </span><span class="cov1" title="1">{ + return nil, fmt.Errorf("provider index %d out of range (0-%d)", idx, len(jobs)-1) + }</span> + <span class="cov3" title="2">if _, ok := seen[idx]; ok </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov3" title="2">clone := jobs[idx] + filtered = append(filtered, clone) + seen[idx] = struct{}{}</span> + } + <span class="cov1" title="1">for i := range filtered </span><span class="cov3" title="2">{ + filtered[i].index = i + }</span> + <span class="cov1" title="1">if len(filtered) == 0 </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("no CLI providers matched selection") + }</span> + <span class="cov1" title="1">return filtered, nil</span> +} + +// readInput reads from stdin and args, then combines them per CLI rules. +func readInput(stdin io.Reader, args []string) (string, error) <span class="cov9" title="11">{ + var stdinData string + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 </span><span class="cov7" title="6">{ + data, readErr := io.ReadAll(stdin) + if readErr != nil </span><span class="cov1" title="1">{ + return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr) + }</span> + <span class="cov6" title="5">stdinData = strings.TrimSpace(string(data))</span> + } + <span class="cov9" title="10">argData := strings.TrimSpace(strings.Join(args, " ")) + switch </span>{ + case stdinData != "" && argData != "":<span class="cov1" title="1"> + return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil</span> + case stdinData != "":<span class="cov3" title="2"> + return stdinData, nil</span> + case argData != "":<span class="cov6" title="5"> + return argData, nil</span> + default:<span class="cov3" title="2"> + return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")</span> + } +} + +// 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 <span class="cov7" title="6">{ + 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") </span><span class="cov1" title="1">{ + 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." + }</span> + <span class="cov7" title="6">return []llm.Message{ + {Role: "system", Content: system}, + {Role: "user", Content: input}, + }</span> +} + +// buildMessagesFromConfig uses configured CLI system prompts. +func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <span class="cov7" title="6">{ + lower := strings.ToLower(input) + system := cfg.PromptCLIDefaultSystem + if strings.Contains(lower, "explain") </span><span class="cov1" title="1">{ + if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{ + system = cfg.PromptCLIExplainSystem + }</span> + } + <span class="cov7" title="6">return []llm.Message{ + {Role: "system", Content: system}, + {Role: "user", Content: input}, + }</span> +} + +// runChat executes the chat request, handling streaming and summary output. +func runChat(ctx context.Context, client llm.Client, req requestArgs, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov8" title="9">{ + start := time.Now() + // Best-effort tmux status update (colored start heartbeat) + model := strings.TrimSpace(req.model) + if model == "" </span><span class="cov0" title="0">{ + model = client.DefaultModel() + }</span> + <span class="cov8" title="9">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), model)) + var output string + if s, ok := client.(llm.Streamer); ok </span><span class="cov3" title="2">{ + var b strings.Builder + var streamErr error + if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov6" title="5">{ + if streamErr != nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov6" title="5">b.WriteString(chunk) + if _, err := fmt.Fprint(out, chunk); err != nil </span><span class="cov0" title="0">{ + streamErr = err + }</span> + }, req.options...); err != nil <span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="2">if streamErr != nil </span><span class="cov0" title="0">{ + return streamErr + }</span> + <span class="cov3" title="2">output = b.String()</span> + } else<span class="cov7" title="7"> { + txt, err := client.Chat(ctx, msgs, req.options...) + if err != nil </span><span class="cov3" title="2">{ + return err + }</span> + <span class="cov6" title="5">output = txt + if _, err := fmt.Fprint(out, output); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + } + <span class="cov7" title="7">dur := time.Since(start) + // Contribute to global stats and update tmux status + sent := 0 + for _, m := range msgs </span><span class="cov10" title="13">{ + sent += len(m.Content) + }</span> + <span class="cov7" title="7">recv := len(output) + _ = stats.Update(ctx, client.Name(), model, sent, recv) + snap, _ := stats.TakeSnapshot() + minsWin := snap.Window.Minutes() + if minsWin <= 0 </span><span class="cov0" title="0">{ + minsWin = 0.001 + }</span> + <span class="cov7" title="7">scopeReqs := int64(0) + if pe, ok := snap.Providers[client.Name()]; ok </span><span class="cov7" title="7">{ + if mc, ok2 := pe.Models[model]; ok2 </span><span class="cov7" title="7">{ + scopeReqs = mc.Reqs + }</span> + } + <span class="cov7" title="7">scopeRPM := float64(scopeReqs) / minsWin + if _, err := fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", + client.Name(), model, dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov7" title="7">_ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), model, scopeRPM, scopeReqs, snap.Window)) + return nil</span> +} + +// printProviderInfo writes the provider/model line to stderr. +func printProviderInfo(errw io.Writer, client llm.Client, model string) <span class="cov7" title="6">{ + if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ + model = client.DefaultModel() + }</span> + <span class="cov7" title="6">_, _ = fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), model)</span> +} + +// newClientFromConfig is kept for tests; delegates to llmutils. +var newClientFromApp = llmutils.NewClientFromApp + +// Backcompat for tests referencing the older helper name. +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov3" title="2">{ return newClientFromApp(cfg) }</span> +</pre> + + <pre class="file" id="file14" style="display: none">// 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" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/ignore" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/lsp" + "codeberg.org/snonux/hexai/internal/runtimeconfig" + "codeberg.org/snonux/hexai/internal/stats" +) + +// 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 <span class="cov1" title="1">{ + return RunWithConfig(logPath, "", stdin, stdout, stderr) +}</span> + +func RunWithConfig(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov1" title="1">{ + logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) + if strings.TrimSpace(logPath) != "" </span><span class="cov1" title="1">{ + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("failed to open log file: %v", err) + }</span> + <span class="cov1" title="1">defer func() </span><span class="cov1" title="1">{ + if err := f.Close(); err != nil </span><span class="cov0" title="0">{ + logger.Printf("failed to close log file: %v", err) + }</span> + }() + <span class="cov1" title="1">logger.SetOutput(f)</span> + } + <span class="cov1" title="1">logging.Bind(logger) + loadOpts := appconfig.LoadOptions{ConfigPath: configPath} + cfg := appconfig.LoadWithOptions(logger, loadOpts) + if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("invalid config: %v", err) + }</span> + <span class="cov1" title="1">if cfg.StatsWindowMinutes > 0 </span><span class="cov1" title="1">{ + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + }</span> + <span class="cov1" title="1">return RunWithFactory(logPath, configPath, stdin, stdout, logger, cfg, nil, nil)</span> +} + +// 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, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov9" title="8">{ + normalizeLoggingConfig(&cfg) + if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("invalid config: %v", err) + }</span> + <span class="cov9" title="8">client = buildClientIfNil(cfg, client) + factory = ensureFactory(factory) + + // Create gitignore-aware file checker for LSP filtering + gitRoot := appconfig.FindGitRoot() + useGI := cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore + ignoreChecker := ignore.New(gitRoot, useGI, cfg.IgnoreExtraPatterns) + + store := runtimeconfig.New(cfg) + logContext := strings.TrimSpace(logPath) != "" + loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)} + opts := makeServerOptions(cfg, logContext, client, loadOpts, ignoreChecker) + opts.ConfigLoadOptions = loadOpts + opts.ConfigStore = store + server := factory(stdin, stdout, logger, opts) + if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok </span><span class="cov3" title="2">{ + store.Subscribe(func(oldCfg, newCfg appconfig.App) </span><span class="cov1" title="1">{ + updated := newCfg + normalizeLoggingConfig(&updated) + if updated.StatsWindowMinutes > 0 </span><span class="cov0" title="0">{ + stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute) + }</span> + <span class="cov1" title="1">if newClient := buildClientIfNil(updated, nil); newClient != nil </span><span class="cov1" title="1">{ + client = newClient + }</span> + // Update ignore checker patterns on config hot-reload + <span class="cov1" title="1">useGI := updated.IgnoreGitignore == nil || *updated.IgnoreGitignore + ignoreChecker.Update(useGI, updated.IgnoreExtraPatterns) + opts := makeServerOptions(updated, logContext, client, loadOpts, ignoreChecker) + opts.ConfigStore = store + configurable.ApplyOptions(opts)</span> + }) + } + <span class="cov9" title="8">if err := server.Run(); err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("server error: %v", err) + }</span> + <span class="cov9" title="8">return nil</span> +} + +// --- helpers to keep RunWithFactory small --- + +func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="9">{ + cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) + if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="9">{ + logging.SetLogPreviewLimit(cfg.LogPreviewLimit) + }</span> +} + +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="9">{ + if client != nil </span><span class="cov1" title="1">{ + return client + }</span> + <span class="cov9" title="8">llmCfg := llm.Config{ + Provider: cfg.Provider, + RequestTimeout: cfg.RequestTimeout, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, + } + // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY + oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") + if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="8">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + // Prefer HEXAI_OPENROUTER_API_KEY; fall back to OPENROUTER_API_KEY + <span class="cov9" title="8">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") + if strings.TrimSpace(orKey) == "" </span><span class="cov9" title="8">{ + orKey = os.Getenv("OPENROUTER_API_KEY") + }</span> + // Prefer HEXAI_ANTHROPIC_API_KEY; fall back to ANTHROPIC_API_KEY + <span class="cov9" title="8">anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") + if strings.TrimSpace(anKey) == "" </span><span class="cov9" title="8">{ + anKey = os.Getenv("ANTHROPIC_API_KEY") + }</span> + <span class="cov9" title="8">if c, err := llm.NewFromConfig(llmCfg, oaKey, orKey, anKey); err != nil </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "llm disabled: %v", err) + return nil + }</span> else<span class="cov8" title="7"> { + logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) + return c + }</span> +} + +func ensureFactory(factory ServerFactory) ServerFactory <span class="cov9" title="8">{ + if factory != nil </span><span class="cov8" title="7">{ + return factory + }</span> + <span class="cov1" title="1">return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner </span><span class="cov1" title="1">{ + return lsp.NewServer(r, w, logger, opts) + }</span> +} + +func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions, ignoreChecker *ignore.Checker) lsp.ServerOptions <span class="cov10" title="9">{ + // Map custom actions from appconfig to lsp type + var customs []lsp.CustomAction + if len(cfg.CustomActions) > 0 </span><span class="cov0" title="0">{ + customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) + for _, ca := range cfg.CustomActions </span><span class="cov0" title="0">{ + customs = append(customs, lsp.CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + }</span> + } + <span class="cov10" title="9">return lsp.ServerOptions{ + ConfigLoadOptions: loadOpts, + LogContext: logContext, + ConfigStore: nil, + Config: &cfg, + 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, + CompletionWaitAll: cfg.CompletionWaitAll, + 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, + PromptSimplifySystem: cfg.PromptCodeActionSimplifySystem, + PromptSimplifyUser: cfg.PromptCodeActionSimplifyUser, + CustomActions: customs, + IgnoreChecker: ignoreChecker, + }</span> +} +</pre> + + <pre class="file" id="file15" style="display: none">// Summary: Thread-safe gitignore-aware file checker that combines .gitignore +// patterns with user-configured extra patterns. Used by the LSP server to +// skip completions and code actions for ignored files. +package ignore + +import ( + "path/filepath" + "strings" + "sync" + + gitignore "github.com/sabhiram/go-gitignore" +) + +// Checker evaluates whether an absolute file path should be ignored based on +// .gitignore patterns and/or user-configured extra patterns. It is safe for +// concurrent use. +type Checker struct { + mu sync.RWMutex + gitRoot string + giMatcher *gitignore.GitIgnore // compiled .gitignore (nil when disabled or missing) + exMatcher *gitignore.GitIgnore // compiled extra patterns (nil when empty) +} + +// New creates a Checker. If useGitignore is true and gitRoot is non-empty, it +// loads .gitignore from gitRoot. extraPatterns are always compiled (gitignore +// syntax). +func New(gitRoot string, useGitignore bool, extraPatterns []string) *Checker <span class="cov7" title="58">{ + c := &Checker{gitRoot: gitRoot} + c.compile(useGitignore, extraPatterns) + return c +}</span> + +// IsIgnored returns whether absPath should be ignored and a human-readable +// reason string. When the checker is nil, nothing is ignored. +func (c *Checker) IsIgnored(absPath string) (ignored bool, reason string) <span class="cov10" title="416">{ + if c == nil </span><span class="cov2" title="3">{ + return false, "" + }</span> + <span class="cov9" title="413">c.mu.RLock() + defer c.mu.RUnlock() + + rel, inside := c.relPath(absPath) + + // Only check gitignore when the path is inside the git root + if inside && c.giMatcher != nil && c.giMatcher.MatchesPath(rel) </span><span class="cov8" title="199">{ + return true, "matched .gitignore pattern" + }</span> + <span class="cov9" title="214">if c.exMatcher != nil && c.exMatcher.MatchesPath(rel) </span><span class="cov5" title="15">{ + return true, "matched extra ignore pattern" + }</span> + <span class="cov8" title="199">return false, ""</span> +} + +// Update recompiles matchers for hot-reload. Thread-safe. +func (c *Checker) Update(useGitignore bool, extraPatterns []string) <span class="cov6" title="34">{ + c.mu.Lock() + defer c.mu.Unlock() + c.compile(useGitignore, extraPatterns) +}</span> + +// compile builds the gitignore and extra-pattern matchers. Must be called +// under c.mu write lock (or during construction). +func (c *Checker) compile(useGitignore bool, extraPatterns []string) <span class="cov7" title="92">{ + c.giMatcher = nil + c.exMatcher = nil + + if useGitignore && c.gitRoot != "" </span><span class="cov7" title="80">{ + giPath := filepath.Join(c.gitRoot, ".gitignore") + if gi, err := gitignore.CompileIgnoreFile(giPath); err == nil </span><span class="cov7" title="80">{ + c.giMatcher = gi + }</span> + } + <span class="cov7" title="92">if len(extraPatterns) > 0 </span><span class="cov6" title="42">{ + c.exMatcher = gitignore.CompileIgnoreLines(extraPatterns...) + }</span> +} + +// relPath converts absPath to a path relative to gitRoot. Returns the +// relative path and true if the path is inside the git root; otherwise +// returns the original path and false. +func (c *Checker) relPath(absPath string) (string, bool) <span class="cov9" title="413">{ + if c.gitRoot == "" </span><span class="cov5" title="18">{ + return absPath, false + }</span> + <span class="cov9" title="395">rel, err := filepath.Rel(c.gitRoot, absPath) + if err != nil || strings.HasPrefix(rel, "..") </span><span class="cov2" title="3">{ + return absPath, false + }</span> + <span class="cov9" title="392">return rel, true</span> +} +</pre> + + <pre class="file" id="file16" style="display: none">// Summary: Anthropic client implementation using Messages API with optional streaming support. +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +// anthropicClient implements Client against Anthropic's Messages API. +type anthropicClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 +} + +type anthropicChatRequest struct { + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicChatResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + StopReason string `json:"stop_reason"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Streaming event types +type anthropicStreamStart struct { + Type string `json:"type"` + Message struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Model string `json:"model"` + } `json:"message"` +} + +type anthropicStreamDelta struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` +} + +type anthropicStreamError struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// Ensure anthropicClient implements Client and Streamer. +var ( + _ Client = (*anthropicClient)(nil) + _ Streamer = (*anthropicClient)(nil) +) + +// Constructor +// newAnthropic constructs an Anthropic client using explicit configuration values. +// The apiKey may be empty; calls will fail until a valid key is supplied. +func newAnthropic(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov10" title="33">{ + return newAnthropicWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newAnthropicWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov10" title="33">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov3" title="3">{ + baseURL = "https://api.anthropic.com/v1" + }</span> + <span class="cov10" title="33">if strings.TrimSpace(model) == "" </span><span class="cov3" title="3">{ + model = "claude-3-5-sonnet-20240620" + }</span> + <span class="cov10" title="33">if timeoutSec <= 0 </span><span class="cov10" title="33">{ + timeoutSec = 30 + }</span> + <span class="cov10" title="33">return anthropicClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("anthropic"), + defaultTemperature: defaultTemp, + }</span> +} + +// Chat sends a request to Anthropic and returns the response. +func (c anthropicClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov7" title="15">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ + return nilStringErr("missing Anthropic API key") + }</span> + <span class="cov7" title="12">o := c.resolveOptions(opts) + start := time.Now() + c.logStart(false, o, messages) + + resp, err := c.sendRequest(ctx, o, messages, false, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov7" title="12">defer func() </span><span class="cov7" title="12">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic", "failed to close response body: %v", err) + }</span> + }() + + <span class="cov7" title="12">if err := handleAnthropicNon2xx(resp, start); err != nil </span><span class="cov3" title="3">{ + return "", err + }</span> + <span class="cov6" title="9">out, err := decodeAnthropicChat(resp, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov6" title="9">return c.extractContent(out, start)</span> +} + +// Name returns the provider's short name. +func (c anthropicClient) Name() string <span class="cov3" title="3">{ return "anthropic" }</span> + +// DefaultModel returns the configured default model name. +func (c anthropicClient) DefaultModel() string <span class="cov3" title="3">{ return c.defaultModel }</span> + +// ChatStream sends a streaming request and invokes onDelta for each text chunk. +func (c anthropicClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov5" title="6">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ + return errors.New("missing Anthropic API key") + }</span> + <span class="cov3" title="3">o := c.resolveOptions(opts) + start := time.Now() + c.logStart(true, o, messages) + + resp, err := c.sendRequest(ctx, o, messages, true, start) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">defer func() </span><span class="cov3" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic", "failed to close response body: %v", err) + }</span> + }() + + <span class="cov3" title="3">if err := handleAnthropicNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">if err := parseAnthropicStream(resp, start, onDelta); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="3">logging.Logf("llm/anthropic ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// Private helpers + +func (c anthropicClient) resolveOptions(opts []RequestOption) Options <span class="cov7" title="15">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov3" title="3">{ + opt(&o) + }</span> + <span class="cov7" title="15">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov7" title="15">return o</span> +} + +func (c anthropicClient) sendRequest(ctx context.Context, o Options, messages []Message, stream bool, start time.Time) (*http.Response, error) <span class="cov7" title="15">{ + req := buildAnthropicChatRequest(o, messages, c.defaultModel, c.defaultTemperature, stream) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return nil, err + }</span> + <span class="cov7" title="15">endpoint := c.baseURL + "/messages" + mode := "POST" + if stream </span><span class="cov3" title="3">{ + mode = "POST (stream)" + }</span> + <span class="cov7" title="15">logging.Logf("llm/anthropic ", "%s %s", mode, endpoint) + return c.doJSON(ctx, endpoint, body, map[string]string{ + "x-api-key": c.apiKey, + "anthropic-version": "2023-06-01", + })</span> +} + +func (c anthropicClient) extractContent(out anthropicChatResponse, start time.Time) (string, error) <span class="cov6" title="9">{ + if len(out.Content) == 0 </span><span class="cov3" title="3">{ + logging.Logf("llm/anthropic ", "%sno content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("anthropic: no content returned") + }</span> + <span class="cov5" title="6">content := out.Content[0].Text + logging.Logf("llm/anthropic ", "success stop_reason=%s size=%d preview=%s%s%s duration=%s", out.StopReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil</span> +} + +func (c anthropicClient) logf(format string, args ...any) <span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", format, args...) +}</span> + +func (c anthropicClient) logStart(stream bool, o Options, messages []Message) <span class="cov7" title="15">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov7" title="15">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov7" title="15">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildAnthropicChatRequest(o Options, messages []Message, defaultModel string, defaultTemp *float64, stream bool) anthropicChatRequest <span class="cov7" title="15">{ + req := anthropicChatRequest{ + Model: o.Model, + Stream: stream, + MaxTokens: 4096, // Anthropic requires max_tokens + } + // Anthropic requires system messages in a top-level "system" field, not in messages array + var systemParts []string + var nonSystemMessages []Message + for _, m := range messages </span><span class="cov7" title="15">{ + if m.Role == "system" </span><span class="cov0" title="0">{ + systemParts = append(systemParts, m.Content) + }</span> else<span class="cov7" title="15"> { + nonSystemMessages = append(nonSystemMessages, m) + }</span> + } + <span class="cov7" title="15">if len(systemParts) > 0 </span><span class="cov0" title="0">{ + req.System = strings.Join(systemParts, "\n\n") + }</span> + <span class="cov7" title="15">req.Messages = make([]anthropicMessage, len(nonSystemMessages)) + for i, m := range nonSystemMessages </span><span class="cov7" title="15">{ + req.Messages[i] = anthropicMessage{ + Role: m.Role, + Content: m.Content, + } + }</span> + <span class="cov7" title="15">if o.Temperature != 0 </span><span class="cov3" title="3">{ + req.Temperature = &o.Temperature + }</span> else<span class="cov7" title="12"> if defaultTemp != nil </span><span class="cov0" title="0">{ + t := *defaultTemp + req.Temperature = &t + }</span> + <span class="cov7" title="15">if o.MaxTokens > 0 </span><span class="cov0" title="0">{ + req.MaxTokens = o.MaxTokens + }</span> + <span class="cov7" title="15">return req</span> +} + +func (c anthropicClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov7" title="15">{ + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov7" title="15">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov9" title="30">{ + req.Header.Set(k, v) + }</span> + <span class="cov7" title="15">return c.httpClient.Do(req)</span> +} + +func handleAnthropicNon2xx(resp *http.Response, start time.Time) error <span class="cov7" title="15">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov7" title="12">{ + return nil + }</span> + <span class="cov3" title="3">var apiErr anthropicChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov3" title="3">{ + logging.Logf("llm/anthropic ", "%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("anthropic error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + }</span> + <span class="cov0" title="0">logging.Logf("llm/anthropic ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("anthropic http error: status %d", resp.StatusCode)</span> +} + +func decodeAnthropicChat(resp *http.Response, start time.Time) (anthropicChatResponse, error) <span class="cov6" title="9">{ + var out anthropicChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return anthropicChatResponse{}, err + }</span> + <span class="cov6" title="9">return out, nil</span> +} + +func parseAnthropicStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov3" title="3">{ + // Parse server-sent events: lines starting with "data: " containing JSON + scanner := bufio.NewScanner(resp.Body) + const maxBuf = 1024 * 1024 + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxBuf) + for scanner.Scan() </span><span class="cov8" title="18">{ + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov8" title="18">payload := strings.TrimPrefix(line, "data: ") + // Check for stream end event + if strings.Contains(payload, "\"type\":\"message_stop\"") </span><span class="cov3" title="3">{ + break</span> + } + // Try to parse as delta event + <span class="cov7" title="15">var delta anthropicStreamDelta + if err := json.Unmarshal([]byte(payload), &delta); err != nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov7" title="15">if delta.Type == "content_block_delta" && delta.Delta.Type == "text_delta" && delta.Delta.Text != "" </span><span class="cov6" title="9">{ + onDelta(delta.Delta.Text) + }</span> + // Check for errors in stream + <span class="cov7" title="15">var errEvent anthropicStreamError + if err := json.Unmarshal([]byte(payload), &errEvent); err == nil </span><span class="cov7" title="15">{ + if errEvent.Type == "error" && errEvent.Error.Message != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sstream error: %s%s", logging.AnsiRed, errEvent.Error.Message, logging.AnsiBase) + return fmt.Errorf("anthropic stream error: %s", errEvent.Error.Message) + }</span> + } + } + <span class="cov3" title="3">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/anthropic ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov3" title="3">return nil</span> +} +</pre> + + <pre class="file" id="file17" style="display: none">// 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 <span class="cov9" title="30">{ + return newOllamaWithTimeout(baseURL, model, defaultTemp, 0) +}</span> + +func newOllamaWithTimeout(baseURL, model string, defaultTemp *float64, timeoutSec int) Client <span class="cov9" title="35">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov4" title="4">{ + baseURL = "http://localhost:11434" + }</span> + <span class="cov9" title="35">if strings.TrimSpace(model) == "" </span><span class="cov4" title="4">{ + model = "qwen3-coder:30b-a3b-q4_K_M" + }</span> + <span class="cov9" title="35">if timeoutSec <= 0 </span><span class="cov9" title="34">{ + timeoutSec = 30 + }</span> + <span class="cov9" title="35">return ollamaClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + defaultModel: model, + chatLogger: logging.NewChatLogger("ollama"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov8" title="18">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov8" title="18">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + + <span class="cov8" title="18">start := time.Now() + c.logStart(false, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, false) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + + <span class="cov8" title="18">endpoint := c.baseURL + "/api/chat" + logging.Logf("llm/ollama ", "POST %s", endpoint) + resp, err := c.doJSON(ctx, endpoint, body) + if err != nil </span><span class="cov3" title="3">{ + logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov7" title="15">defer func() </span><span class="cov7" title="15">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama", "failed to close response body: %v", err) + }</span> + }() + <span class="cov7" title="15">if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov5" title="6">{ + return "", err + }</span> + + <span class="cov6" title="9">var out ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov3" title="3">{ + logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov5" title="6">if strings.TrimSpace(out.Message.Content) == "" </span><span class="cov3" title="3">{ + logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("ollama: empty content") + }</span> + <span class="cov3" title="3">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</span> +} + +// Provider metadata +func (c ollamaClient) Name() string <span class="cov4" title="4">{ return "ollama" }</span> +func (c ollamaClient) DefaultModel() string <span class="cov4" title="4">{ return c.defaultModel }</span> + +// Streaming support (optional) +func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="9">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov6" title="9">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + + <span class="cov6" title="9">start := time.Now() + c.logStart(true, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, true) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov6" title="9">endpoint := c.baseURL + "/api/chat" + logging.Logf("llm/ollama ", "POST %s (stream)", endpoint) + resp, err := c.doJSON(ctx, endpoint, body) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov6" title="9">defer func() </span><span class="cov6" title="9">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama", "failed to close response body: %v", err) + }</span> + }() + <span class="cov6" title="9">if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov6" title="9">dec := json.NewDecoder(resp.Body) + for </span><span class="cov7" title="12">{ + var ev ollamaChatResponse + if err := dec.Decode(&ev); err != nil </span><span class="cov3" title="3">{ + if errors.Is(err, io.EOF) </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov3" title="3">logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err</span> + } + <span class="cov6" title="9">if strings.TrimSpace(ev.Error) != "" </span><span class="cov3" title="3">{ + logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase) + return fmt.Errorf("ollama stream error: %s", ev.Error) + }</span> + <span class="cov5" title="6">if s := ev.Message.Content; strings.TrimSpace(s) != "" </span><span class="cov5" title="6">{ + onDelta(s) + }</span> + <span class="cov5" title="6">if ev.Done </span><span class="cov3" title="3">{ + break</span> + } + } + <span class="cov3" title="3">logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// helpers to keep methods small +func (c ollamaClient) logStart(stream bool, o Options, messages []Message) <span class="cov9" title="27">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov9" title="27">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov9" title="27">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest <span class="cov10" title="36">{ + req := ollamaChatRequest{Model: o.Model, Stream: stream} + req.Messages = make([]oaMessage, len(messages)) + for i, m := range messages </span><span class="cov10" title="36">{ + req.Messages[i] = oaMessage(m) + }</span> + <span class="cov10" title="36">optsMap := map[string]any{} + if o.Temperature != 0 </span><span class="cov3" title="3">{ + optsMap["temperature"] = o.Temperature + }</span> else<span class="cov9" title="33"> if defaultTemp != nil </span><span class="cov6" title="9">{ + optsMap["temperature"] = *defaultTemp + }</span> + <span class="cov10" title="36">if o.MaxTokens > 0 </span><span class="cov5" title="6">{ + optsMap["num_predict"] = o.MaxTokens + }</span> + <span class="cov10" title="36">if len(o.Stop) > 0 </span><span class="cov5" title="6">{ + optsMap["stop"] = o.Stop + }</span> + <span class="cov10" title="36">if len(optsMap) > 0 </span><span class="cov7" title="12">{ + req.Options = optsMap + }</span> + <span class="cov10" title="36">return req</span> +} + +func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov9" title="27">{ + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov9" title="27">req.Header.Set("Content-Type", "application/json") + return c.httpClient.Do(req)</span> +} + +func handleOllamaNon2xx(resp *http.Response, start time.Time) error <span class="cov9" title="27">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="21">{ + return nil + }</span> + <span class="cov5" title="6">var apiErr ollamaChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if strings.TrimSpace(apiErr.Error) != "" </span><span class="cov3" title="3">{ + 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) + }</span> + <span class="cov3" title="3">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)</span> +} +</pre> + + <pre class="file" id="file18" style="display: none">// 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"` + MaxCompletionTokens *int `json:"max_completion_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 <span class="cov8" title="30">{ + return newOpenAIWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newOpenAIWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov9" title="56">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov7" title="23">{ + baseURL = "https://api.openai.com/v1" + }</span> + <span class="cov9" title="56">if strings.TrimSpace(model) == "" </span><span class="cov6" title="14">{ + model = "gpt-4.1" + }</span> + <span class="cov9" title="56">if timeoutSec <= 0 </span><span class="cov9" title="53">{ + timeoutSec = 30 + }</span> + <span class="cov9" title="56">return openAIClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("openai"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov8" title="29">{ + if c.apiKey == "" </span><span class="cov3" title="3">{ + return nilStringErr("missing OpenAI API key") + }</span> + <span class="cov7" title="26">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov4" title="5">{ + opt(&o) + }</span> + <span class="cov7" title="26">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov7" title="26">start := time.Now() + c.logStart(false, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openai ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return "", err + }</span> + <span class="cov7" title="26">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 </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov7" title="26">defer func() </span><span class="cov7" title="26">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai", "failed to close response body: %v", err) + }</span> + }() + <span class="cov7" title="26">if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov4" title="6">{ + return "", err + }</span> + <span class="cov7" title="20">out, err := decodeOpenAIChat(resp, start, "llm/openai ") + if err != nil </span><span class="cov3" title="3">{ + return "", err + }</span> + <span class="cov7" title="17">if len(out.Choices) == 0 </span><span class="cov3" title="3">{ + logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("openai: no choices returned") + }</span> + <span class="cov6" title="14">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</span> +} + +// Provider metadata +func (c openAIClient) Name() string <span class="cov6" title="16">{ return "openai" }</span> +func (c openAIClient) DefaultModel() string <span class="cov6" title="14">{ return c.defaultModel }</span> + +// Streaming support (optional) + +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov6" title="15">{ + if c.apiKey == "" </span><span class="cov0" title="0">{ + return errors.New("missing OpenAI API key") + }</span> + <span class="cov6" title="15">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov6" title="15">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov6" title="15">start := time.Now() + c.logStart(true, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openai ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return err + }</span> + <span class="cov6" title="15">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 </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov6" title="15">defer func() </span><span class="cov6" title="15">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai", "failed to close response body: %v", err) + }</span> + }() + <span class="cov6" title="15">if err := handleOpenAINon2xx(resp, start, "llm/openai ", "openai"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov6" title="15">if err := parseOpenAIStream(resp, start, onDelta, "llm/openai ", "openai"); err != nil </span><span class="cov3" title="3">{ + return err + }</span> + <span class="cov6" title="12">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// Private helpers +func (c openAIClient) logf(format string, args ...any) <span class="cov0" title="0">{ logging.Logf("llm/openai ", format, args...) }</span> + +// helpers extracted to keep methods small +func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov8" title="41">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov9" title="46">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov8" title="41">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool, logPrefix string) oaChatRequest <span class="cov9" title="56">{ + req := oaChatRequest{Model: o.Model, Stream: stream} + req.Messages = make([]oaMessage, len(messages)) + for i, m := range messages </span><span class="cov9" title="61">{ + req.Messages[i] = oaMessage(m) + }</span> + <span class="cov9" title="56">if o.Temperature != 0 </span><span class="cov3" title="3">{ + req.Temperature = &o.Temperature + }</span> else<span class="cov9" title="53"> if defaultTemp != nil </span><span class="cov8" title="38">{ + t := *defaultTemp + req.Temperature = &t + }</span> + <span class="cov9" title="56">if o.MaxTokens > 0 </span><span class="cov6" title="14">{ + if requiresMaxCompletionTokens(o.Model) </span><span class="cov4" title="6">{ + req.MaxCompletionTokens = &o.MaxTokens + }</span> else<span class="cov5" title="8"> { + req.MaxTokens = &o.MaxTokens + }</span> + } + <span class="cov9" title="56">if len(o.Stop) > 0 </span><span class="cov0" title="0">{ + req.Stop = o.Stop + }</span> + // Enforce gpt-5 temperature constraints: only default (1.0) is supported. + <span class="cov9" title="56">if requiresMaxCompletionTokens(o.Model) </span><span class="cov4" title="6">{ + if req.Temperature == nil || *req.Temperature != 1.0 </span><span class="cov4" title="6">{ + t := 1.0 + req.Temperature = &t + logging.Logf(logPrefix, "forcing temperature=1.0 for model=%s (gpt-5 constraint)", o.Model) + }</span> + } + <span class="cov9" title="56">return req</span> +} + +// requiresMaxCompletionTokens reports whether the given model prefers the +// new parameter name "max_completion_tokens" instead of "max_tokens". Newer +// models (e.g., gpt-5 family) expect this per OpenAI's API error guidance. +func requiresMaxCompletionTokens(model string) bool <span class="cov10" title="70">{ + m := strings.ToLower(strings.TrimSpace(model)) + return strings.HasPrefix(m, "gpt-5") +}</span> + +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov7" title="26">{ + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov7" title="26">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov7" title="26">{ + req.Header.Set(k, v) + }</span> + <span class="cov7" title="26">return c.httpClient.Do(req)</span> +} + +func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov6" title="15">{ + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov6" title="15">req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", accept) + for k, v := range headers </span><span class="cov6" title="15">{ + req.Header.Set(k, v) + }</span> + <span class="cov6" title="15">return c.httpClient.Do(req)</span> +} + +func handleOpenAINon2xx(resp *http.Response, start time.Time, logPrefix, provider string) error <span class="cov9" title="50">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov8" title="41">{ + return nil + }</span> + <span class="cov5" title="9">var apiErr oaChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov3" title="3">{ + logging.Logf(logPrefix, "%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("%s error: %s (status %d)", provider, apiErr.Error.Message, resp.StatusCode) + }</span> + <span class="cov4" title="6">logging.Logf(logPrefix, "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("%s http error: status %d", provider, resp.StatusCode)</span> +} + +func decodeOpenAIChat(resp *http.Response, start time.Time, logPrefix string) (oaChatResponse, error) <span class="cov7" title="23">{ + var out oaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov3" title="3">{ + logging.Logf(logPrefix, "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return oaChatResponse{}, err + }</span> + <span class="cov7" title="20">return out, nil</span> +} + +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string), logPrefix, provider string) error <span class="cov7" title="18">{ + // 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() </span><span class="cov8" title="42">{ + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") </span><span class="cov5" title="9">{ + continue</span> + } + <span class="cov8" title="33">payload := strings.TrimPrefix(line, "data: ") + if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov6" title="12">{ + break</span> + } + <span class="cov7" title="21">var chunk oaStreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov4" title="6">{ + continue</span> + } + <span class="cov6" title="15">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov3" title="3">{ + logging.Logf(logPrefix, "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) + return fmt.Errorf("%s stream error: %s", provider, chunk.Error.Message) + }</span> + <span class="cov6" title="12">for _, ch := range chunk.Choices </span><span class="cov6" title="12">{ + if ch.Delta.Content != "" </span><span class="cov5" title="9">{ + onDelta(ch.Delta.Content) + }</span> + } + } + <span class="cov6" title="15">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ + logging.Logf(logPrefix, "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov6" title="15">return nil</span> +} +</pre> + + <pre class="file" id="file19" style="display: none">// Summary: OpenRouter client implementation leveraging OpenAI-compatible helpers with provider-specific headers. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +type openRouterClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 +} + +func newOpenRouter(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov8" title="12">{ + return newOpenRouterWithTimeout(baseURL, model, apiKey, defaultTemp, 0) +}</span> + +func newOpenRouterWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, timeoutSec int) Client <span class="cov8" title="12">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov4" title="3">{ + baseURL = "https://openrouter.ai/api/v1" + }</span> + <span class="cov8" title="12">if strings.TrimSpace(model) == "" </span><span class="cov4" title="3">{ + model = "openrouter/auto" + }</span> + <span class="cov8" title="12">if timeoutSec <= 0 </span><span class="cov8" title="12">{ + timeoutSec = 30 + }</span> + <span class="cov8" title="12">return openRouterClient{ + httpClient: &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("openrouter"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c openRouterClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov6" title="6">{ + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov4" title="3">{ + return nilStringErr("missing OpenRouter API key") + }</span> + <span class="cov4" title="3">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov4" title="3">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov4" title="3">start := time.Now() + c.logStart(false, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, false, "llm/openrouter ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return "", err + }</span> + <span class="cov4" title="3">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" + logging.Logf("llm/openrouter ", "POST %s", endpoint) + resp, err := c.doJSON(ctx, endpoint, body) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="3">if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov4" title="3">out, err := decodeOpenAIChat(resp, start, "llm/openrouter ") + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov4" title="3">if len(out.Choices) == 0 </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("openrouter: no choices returned") + }</span> + <span class="cov4" title="3">content := out.Choices[0].Message.Content + logging.Logf("llm/openrouter ", "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</span> +} + +func (c openRouterClient) Name() string <span class="cov4" title="3">{ return "openrouter" }</span> +func (c openRouterClient) DefaultModel() string <span class="cov4" title="3">{ return c.defaultModel }</span> + +func (c openRouterClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov4" title="3">{ + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + return errors.New("missing OpenRouter API key") + }</span> + <span class="cov4" title="3">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov4" title="3">if strings.TrimSpace(o.Model) == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov4" title="3">start := time.Now() + c.logStart(true, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, true, "llm/openrouter ") + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return err + }</span> + <span class="cov4" title="3">endpoint := strings.TrimRight(c.baseURL, "/") + "/chat/completions" + logging.Logf("llm/openrouter ", "POST %s (stream)", endpoint) + resp, err := c.doJSONWithAccept(ctx, endpoint, body, "text/event-stream") + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov4" title="3">defer func() </span><span class="cov4" title="3">{ + if err := resp.Body.Close(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openrouter", "failed to close response body: %v", err) + }</span> + }() + <span class="cov4" title="3">if err := handleOpenAINon2xx(resp, start, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="3">if err := parseOpenAIStream(resp, start, onDelta, "llm/openrouter ", "openrouter"); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="3">logging.Logf("llm/openrouter ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +func (c openRouterClient) logf(format string, args ...any) <span class="cov4" title="3">{ + logging.Logf("llm/openrouter ", format, args...) +}</span> + +func (c openRouterClient) logStart(stream bool, o Options, messages []Message) <span class="cov6" title="6">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov6" title="6">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov6" title="6">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func (c openRouterClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov4" title="3">{ + headers := map[string]string{ + "Authorization": "Bearer " + c.apiKey, + "HTTP-Referer": "https://github.com/snonux/hexai", + "X-Title": "Hexai", + } + return c.doJSONWithHeaders(ctx, url, body, headers, "") +}</span> + +func (c openRouterClient) doJSONWithAccept(ctx context.Context, url string, body []byte, accept string) (*http.Response, error) <span class="cov4" title="3">{ + headers := map[string]string{ + "Authorization": "Bearer " + c.apiKey, + "HTTP-Referer": "https://github.com/snonux/hexai", + "X-Title": "Hexai", + } + return c.doJSONWithHeaders(ctx, url, body, headers, accept) +}</span> + +func (c openRouterClient) doJSONWithHeaders(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov6" title="6">{ + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov6" title="6">req.Header.Set("Content-Type", "application/json") + if strings.TrimSpace(accept) != "" </span><span class="cov4" title="3">{ + req.Header.Set("Accept", accept) + }</span> + <span class="cov6" title="6">for k, v := range headers </span><span class="cov10" title="18">{ + req.Header.Set(k, v) + }</span> + <span class="cov6" title="6">return c.httpClient.Do(req)</span> +} +</pre> + + <pre class="file" id="file20" style="display: none">// 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 <span class="cov5" title="10">{ return func(o *Options) </span><span class="cov5" title="8">{ o.Model = model }</span> } +func WithTemperature(t float64) RequestOption <span class="cov8" title="32">{ return func(o *Options) </span><span class="cov6" title="12">{ o.Temperature = t }</span> } +func WithMaxTokens(n int) RequestOption <span class="cov10" title="69">{ return func(o *Options) </span><span class="cov6" title="12">{ o.MaxTokens = n }</span> } +func WithStop(stop ...string) RequestOption <span class="cov3" title="3">{ + return func(o *Options) </span><span class="cov3" title="3">{ o.Stop = append([]string{}, stop...) }</span> +} + +// Config defines provider configuration read from the Hexai config file. +type Config struct { + Provider string + RequestTimeout int // seconds; 0 means use default (30s) + // OpenAI options + OpenAIBaseURL string + OpenAIModel string + OpenAITemperature *float64 + // OpenRouter options + OpenRouterBaseURL string + OpenRouterModel string + OpenRouterTemperature *float64 + // Ollama options + OllamaBaseURL string + OllamaModel string + OllamaTemperature *float64 + // Anthropic options + AnthropicBaseURL string + AnthropicModel string + AnthropicTemperature *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, openRouterAPIKey, anthropicAPIKey string) (Client, error) <span class="cov8" title="41">{ + p := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if p == "" </span><span class="cov5" title="8">{ + p = "openai" + }</span> + <span class="cov8" title="41">switch p </span>{ + case "openai":<span class="cov8" title="33"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="7">{ + return nil, errors.New("missing OPENAI_API_KEY for provider openai") + }</span> + // Default temperature selection: + // - When model is gpt-5*, prefer 1.0 by default (more exploratory). + // - Otherwise, prefer 0.2 by default (coding friendly). + // The app-wide defaults currently set provider temps to 0.2. + // If the user hasn't explicitly overridden and the model is gpt-5*, + // upgrade 0.2 → 1.0 to satisfy the requested default for gpt-5. + <span class="cov7" title="26">model := strings.ToLower(strings.TrimSpace(cfg.OpenAIModel)) + if strings.HasPrefix(model, "gpt-5") </span><span class="cov4" title="6">{ + if cfg.OpenAITemperature == nil </span><span class="cov3" title="3">{ + v := 1.0 + cfg.OpenAITemperature = &v + }</span> else<span class="cov3" title="3"> if *cfg.OpenAITemperature == 0.2 </span><span class="cov3" title="3">{ + v := 1.0 + cfg.OpenAITemperature = &v + }</span> + } else<span class="cov7" title="20"> if cfg.OpenAITemperature == nil </span><span class="cov7" title="17">{ + v := 0.2 + cfg.OpenAITemperature = &v + }</span> + <span class="cov7" title="26">return newOpenAIWithTimeout(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature, cfg.RequestTimeout), nil</span> + case "openrouter":<span class="cov0" title="0"> + if strings.TrimSpace(openRouterAPIKey) == "" </span><span class="cov0" title="0">{ + return nil, errors.New("missing OPENROUTER_API_KEY for provider openrouter") + }</span> + <span class="cov0" title="0">if cfg.OpenRouterTemperature == nil </span><span class="cov0" title="0">{ + t := 0.2 + cfg.OpenRouterTemperature = &t + }</span> + <span class="cov0" title="0">return newOpenRouterWithTimeout(cfg.OpenRouterBaseURL, cfg.OpenRouterModel, openRouterAPIKey, cfg.OpenRouterTemperature, cfg.RequestTimeout), nil</span> + case "ollama":<span class="cov4" title="5"> + if cfg.OllamaTemperature == nil </span><span class="cov3" title="4">{ + t := 0.2 + cfg.OllamaTemperature = &t + }</span> + <span class="cov4" title="5">return newOllamaWithTimeout(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature, cfg.RequestTimeout), nil</span> + case "anthropic":<span class="cov0" title="0"> + if strings.TrimSpace(anthropicAPIKey) == "" </span><span class="cov0" title="0">{ + return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic") + }</span> + <span class="cov0" title="0">if cfg.AnthropicTemperature == nil </span><span class="cov0" title="0">{ + t := 0.2 + cfg.AnthropicTemperature = &t + }</span> + <span class="cov0" title="0">return newAnthropicWithTimeout(cfg.AnthropicBaseURL, cfg.AnthropicModel, anthropicAPIKey, cfg.AnthropicTemperature, cfg.RequestTimeout), nil</span> + default:<span class="cov3" title="3"> + return nil, errors.New("unknown LLM provider: " + p)</span> + } +} +</pre> + + <pre class="file" id="file21" style="display: none">package llm + +import "errors" + +// small helper to keep return type consistent +func nilStringErr(msg string) (string, error) <span class="cov10" title="12">{ return "", errors.New(msg) }</span> +</pre> + + <pre class="file" id="file22" style="display: none">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) <span class="cov10" title="10">{ + llmCfg := llm.Config{ + Provider: cfg.Provider, + RequestTimeout: cfg.RequestTimeout, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, + } + oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") + if strings.TrimSpace(oaKey) == "" </span><span class="cov8" title="7">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + <span class="cov10" title="10">orKey := os.Getenv("HEXAI_OPENROUTER_API_KEY") + if strings.TrimSpace(orKey) == "" </span><span class="cov10" title="10">{ + orKey = os.Getenv("OPENROUTER_API_KEY") + }</span> + <span class="cov10" title="10">anKey := os.Getenv("HEXAI_ANTHROPIC_API_KEY") + if strings.TrimSpace(anKey) == "" </span><span class="cov10" title="10">{ + anKey = os.Getenv("ANTHROPIC_API_KEY") + }</span> + <span class="cov10" title="10">return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey)</span> +} +</pre> + + <pre class="file" id="file23" style="display: none">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 <span class="cov10" title="148">{ + return ChatLogger{Provider: provider} +}</span> + +// 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 +}, +) <span class="cov9" title="92">{ + chatOrStream := "chat" + if stream </span><span class="cov7" title="33">{ + chatOrStream = "stream" + }</span> + <span class="cov9" title="92">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 </span><span class="cov9" title="97">{ + 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) + }</span> +} +</pre> + + <pre class="file" id="file24" style="display: none">// 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) <span class="cov4" title="8">{ std = l }</span> + +// Logf prints a formatted message with a module prefix and base ANSI style. +func Logf(prefix, format string, args ...any) <span class="cov10" title="464">{ + if std == nil </span><span class="cov9" title="390">{ + return + }</span> + <span class="cov7" title="74">msg := fmt.Sprintf(format, args...) + std.Print(AnsiBase + prefix + msg + AnsiReset)</span> +} + +// 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) <span class="cov4" title="13">{ logPreviewLimit = n }</span> + +// PreviewForLog returns the string truncated to the configured preview limit. +func PreviewForLog(s string) string <span class="cov8" title="128">{ + if logPreviewLimit > 0 </span><span class="cov3" title="7">{ + if len(s) <= logPreviewLimit </span><span class="cov0" title="0">{ + return s + }</span> + <span class="cov3" title="7">return s[:logPreviewLimit] + "…"</span> + } + <span class="cov8" title="121">return s</span> +} +</pre> + + <pre class="file" id="file25" style="display: none">package lsp + +import ( + "fmt" + "strings" + + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +type chatCommandResult struct { + message string +} + +func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) <span class="cov10" title="13">{ + trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt)) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") </span><span class="cov8" title="8">{ + return chatCommandResult{}, false + }</span> + + <span class="cov6" title="5">switch </span>{ + case strings.HasPrefix(trimmed, "/reload"):<span class="cov1" title="1"> + return s.handleReloadCommand(), true</span> + case strings.HasPrefix(trimmed, "/help"):<span class="cov0" title="0"> + return s.handleHelpCommand(), true</span> + case strings.HasPrefix(trimmed, "/disable"):<span class="cov3" title="2"> + return s.handleDisableCompletionCommand(), true</span> + case strings.HasPrefix(trimmed, "/enable"):<span class="cov3" title="2"> + return s.handleEnableCompletionCommand(), true</span> + default:<span class="cov0" title="0"> + return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true</span> + } +} + +func (s *Server) handleHelpCommand() chatCommandResult <span class="cov1" title="1">{ + lines := []string{ + "Available slash commands:", + "- /reload?> reload configuration from file (ignores env overrides)", + "- /disable?> disable auto-completions for this session", + "- /enable?> re-enable auto-completions", + } + return chatCommandResult{message: strings.Join(lines, "\n")} +}</span> + +func (s *Server) handleReloadCommand() chatCommandResult <span class="cov3" title="2">{ + if s.configStore == nil </span><span class="cov0" title="0">{ + return chatCommandResult{message: "Reload unavailable: no config store"} + }</span> + <span class="cov3" title="2">loadOpts := s.configLoadOpts + loadOpts.IgnoreEnv = true + changes, err := s.configStore.Reload(s.logger, loadOpts) + if err != nil </span><span class="cov0" title="0">{ + s.logger.Printf("config reload failed: %v", err) + return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)} + }</span> + <span class="cov3" title="2">summary := runtimeconfig.FormatSummary("Reloaded config", changes) + s.logger.Print(summary) + return chatCommandResult{message: summary}</span> +} + +func (s *Server) handleDisableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ + prev := s.setCompletionsDisabled(true) + if prev </span><span class="cov1" title="1">{ + return chatCommandResult{message: "Auto-completions were already disabled."} + }</span> + <span class="cov1" title="1">return chatCommandResult{message: "Auto-completions disabled. Use /enable?> to restore."}</span> +} + +func (s *Server) handleEnableCompletionCommand() chatCommandResult <span class="cov3" title="2">{ + prev := s.setCompletionsDisabled(false) + if !prev </span><span class="cov1" title="1">{ + return chatCommandResult{message: "Auto-completions are already enabled."} + }</span> + <span class="cov1" title="1">return chatCommandResult{message: "Auto-completions enabled."}</span> +} +</pre> + + <pre class="file" id="file26" style="display: none">// 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) <span class="cov10" title="14">{ + mode := s.contextMode() + switch mode </span>{ + case "minimal":<span class="cov3" title="2"> + return "", false</span> + case "window":<span class="cov1" title="1"> + return s.windowContext(uri, pos), true</span> + case "file-on-new-func":<span class="cov8" title="9"> + if newFunc </span><span class="cov3" title="2">{ + return s.fullFileContext(uri), true + }</span> + <span class="cov7" title="7">return "", false</span> + case "always-full":<span class="cov3" title="2"> + return s.fullFileContext(uri), true</span> + default:<span class="cov0" title="0"> + // fallback to minimal if unknown + return "", false</span> + } +} + +func (s *Server) windowContext(uri string, pos Position) string <span class="cov3" title="2">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) + return "" + }</span> + <span class="cov3" title="2">n := len(d.lines) + half := s.windowLines() / 2 + start := pos.Line - half + if start < 0 </span><span class="cov0" title="0">{ + start = 0 + }</span> + <span class="cov3" title="2">end := pos.Line + half + 1 + if end > n </span><span class="cov0" title="0">{ + end = n + }</span> + <span class="cov3" title="2">text := strings.Join(d.lines[start:end], "\n") + return truncateToApproxTokens(text, s.maxContextTokens())</span> +} + +func (s *Server) fullFileContext(uri string) string <span class="cov5" title="4">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) + return "" + }</span> + <span class="cov5" title="4">return truncateToApproxTokens(d.text, s.maxContextTokens())</span> +} + +// 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 <span class="cov7" title="7">{ + if maxTokens <= 0 </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov7" title="7">maxChars := maxTokens * 4 + if len(text) <= maxChars </span><span class="cov7" title="6">{ + return text + }</span> + // try to cut on a line boundary near maxChars + <span class="cov1" title="1">cut := maxChars + if cut > len(text) </span><span class="cov0" title="0">{ + cut = len(text) + }</span> + <span class="cov1" title="1">if i := strings.LastIndex(text[:cut], "\n"); i > 0 </span><span class="cov0" title="0">{ + cut = i + }</span> + <span class="cov1" title="1">return text[:cut]</span> +} +</pre> + + <pre class="file" id="file27" style="display: none">// 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) <span class="cov8" title="42">{ + s.mu.Lock() + defer s.mu.Unlock() + s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} +}</span> + +func (s *Server) deleteDocument(uri string) <span class="cov1" title="1">{ + s.mu.Lock() + defer s.mu.Unlock() + delete(s.docs, uri) +}</span> + +func (s *Server) markActivity() <span class="cov3" title="4">{ + s.mu.Lock() + s.lastInput = time.Now() + s.mu.Unlock() +}</span> + +func (s *Server) getDocument(uri string) *document <span class="cov10" title="91">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.docs[uri] +}</span> + +// splitLines splits the input string into lines, normalizing line endings to '\n'. +func splitLines(sx string) []string <span class="cov8" title="54">{ + sx = strings.ReplaceAll(sx, "\r\n", "\n") + return strings.Split(sx, "\n") +}</span> + +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) <span class="cov5" title="8">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov1" title="1">{ + return "", "", "", "" + }</span> + <span class="cov4" title="7">idx := pos.Line + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov4" title="7">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + idx = len(d.lines) - 1 + }</span> + <span class="cov4" title="7">current = d.lines[idx] + if idx-1 >= 0 </span><span class="cov4" title="6">{ + above = d.lines[idx-1] + }</span> + <span class="cov4" title="7">if idx+1 < len(d.lines) </span><span class="cov4" title="6">{ + below = d.lines[idx+1] + }</span> + <span class="cov4" title="7">for i := idx; i >= 0; i-- </span><span class="cov5" title="9">{ + line := strings.TrimSpace(d.lines[i]) + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov4" title="6">{ + funcCtx = line + break</span> + } + } + <span class="cov4" title="7">return above, current, below, funcCtx</span> +} + +// 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 <span class="cov5" title="12">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="12">idx := pos.Line + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov5" title="12">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + idx = len(d.lines) - 1 + }</span> + // Find signature start + <span class="cov5" title="12">sigStart := -1 + for i := idx; i >= 0; i-- </span><span class="cov7" title="21">{ + if strings.Contains(d.lines[i], "func ") </span><span class="cov3" title="4">{ + sigStart = i + break</span> + } + // stop if we hit a closing brace which likely ends a previous block + <span class="cov6" title="17">if strings.Contains(d.lines[i], "}") </span><span class="cov0" title="0">{ + break</span> + } + } + <span class="cov5" title="12">if sigStart == -1 </span><span class="cov5" title="8">{ + return false + }</span> + // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body + <span class="cov3" title="4">for i := sigStart; i <= idx; i++ </span><span class="cov4" title="6">{ + line := d.lines[i] + brace := strings.Index(line, "{") + if brace >= 0 </span><span class="cov2" title="2">{ + if i < idx </span><span class="cov1" title="1">{ + return false // body started on a previous line + }</span> + // same line as cursor: if brace position < cursor character, then already in body + <span class="cov1" title="1">if pos.Character > brace </span><span class="cov1" title="1">{ + return false + }</span> + } + } + <span class="cov2" title="2">return true</span> +} + +func hasAny(s string, needles []string) bool <span class="cov5" title="9">{ + for _, n := range needles </span><span class="cov7" title="24">{ + if strings.Contains(s, n) </span><span class="cov4" title="6">{ + return true + }</span> + } + <span class="cov3" title="3">return false</span> +} + +func trimLen(s string) string <span class="cov8" title="47">{ + s = strings.TrimSpace(s) + if len(s) > 200 </span><span class="cov1" title="1">{ + return s[:200] + "…" + }</span> + <span class="cov8" title="46">return s</span> +} + +func firstLine(s string) string <span class="cov7" title="25">{ + s = strings.ReplaceAll(s, "\r\n", "\n") + if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="6">{ + return s[:idx] + }</span> + <span class="cov6" title="19">return s</span> +} +</pre> + + <pre class="file" id="file28" style="display: none">// 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) <span class="cov2" title="2">{ + if h, ok := s.handlers[req.Method]; ok </span><span class="cov1" title="1">{ + h(req) + return + }</span> + <span class="cov1" title="1">if len(req.ID) != 0 </span><span class="cov1" title="1">{ + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) + }</span> +} + +// 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 (s *Server) instructionFromSelection(sel string) (string, string) <span class="cov4" title="5">{ + lines := splitLines(sel) + for idx, line := range lines </span><span class="cov4" title="5">{ + if instr, cleaned, ok := s.findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + }</span> + } + <span class="cov4" title="4">return "", sel</span> +} + +// 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 (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="24">{ + type cand struct { + start, end int + text string + } + cands := []cand{} + openStr, _, openChar, closeChar := s.inlineMarkers() + if t, l, r, ok := findStrictInlineTag(line, openStr, openChar, closeChar); ok </span><span class="cov5" title="6">{ + cands = append(cands, cand{start: l, end: r, text: t}) + }</span> + <span class="cov8" title="24">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ + if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov2" title="2">{ + 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}) + }</span> + } + <span class="cov8" title="24">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov2" title="2">{ + if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov2" title="2">{ + 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}) + }</span> + } + <span class="cov8" title="24">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="4">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov8" title="24">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov2" title="2">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + }</span> + <span class="cov8" title="24">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="4">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov8" title="24">if len(cands) == 0 </span><span class="cov5" title="8">{ + return "", line, false + }</span> + // pick earliest start index + <span class="cov7" title="16">best := cands[0] + for _, c := range cands[1:] </span><span class="cov4" title="4">{ + if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov1" title="1">{ + best = c + }</span> + } + <span class="cov7" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true</span> +} + +// 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) <span class="cov8" title="31">{ + resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} + s.writeMessage(resp) +}</span> + +// 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 <span class="cov6" title="11">{ + // Normalize left-of-cursor by trimming trailing spaces/tabs + idx := p.Position.Character + if idx > len(current) </span><span class="cov0" title="0">{ + idx = len(current) + }</span> + <span class="cov6" title="11">left := strings.TrimRight(current[:idx], " \t") + right := "" + if idx < len(current) </span><span class="cov0" title="0">{ + right = current[idx:] + }</span> + <span class="cov6" title="11">prov := "" + model := "" + if client := s.currentLLMClient(); client != nil </span><span class="cov6" title="11">{ + prov = client.Name() + model = client.DefaultModel() + }</span> + <span class="cov6" title="11">temp := "" + if tempPtr := s.codingTemperature(); tempPtr != nil </span><span class="cov0" title="0">{ + temp = fmt.Sprintf("%.3f", *tempPtr) + }</span> + <span class="cov6" title="11">extra := "" + if hasExtra </span><span class="cov0" title="0">{ + extra = strings.TrimSpace(extraText) + }</span> + // Compose a key from essential context parts + <span class="cov6" title="11">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")</span> // use unit separator to avoid collisions +} + +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6" title="11">{ + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.compCache[key] + if !ok </span><span class="cov6" title="10">{ + return "", false + }</span> + // move to most-recent + <span class="cov1" title="1">s.compCacheTouchLocked(key) + return v, true</span> +} + +func (s *Server) completionCachePut(key, value string) <span class="cov6" title="13">{ + s.mu.Lock() + defer s.mu.Unlock() + if s.compCache == nil </span><span class="cov4" title="5">{ + s.compCache = make(map[string]string) + }</span> + <span class="cov6" title="13">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="13">{ + s.compCacheOrder = append(s.compCacheOrder, key) + s.compCache[key] = value + if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ + // evict oldest + old := s.compCacheOrder[0] + s.compCacheOrder = s.compCacheOrder[1:] + delete(s.compCache, old) + }</span> + <span class="cov6" title="13">return</span> + } + // update existing and mark most-recent + <span class="cov0" title="0">s.compCache[key] = value + s.compCacheTouchLocked(key)</span> +} + +func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ + // assumes s.mu is held + // remove any existing occurrence of key in order slice + idx := -1 + for i, k := range s.compCacheOrder </span><span class="cov1" title="1">{ + if k == key </span><span class="cov1" title="1">{ + idx = i + break</span> + } + } + <span class="cov1" title="1">if idx >= 0 </span><span class="cov1" title="1">{ + s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...) + }</span> + <span class="cov1" title="1">s.compCacheOrder = append(s.compCacheOrder, key)</span> +} + +// 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 <span class="cov8" title="25">{ + open, _, openChar, closeChar := s.inlineMarkers() + doubleSeqs := doubleOpenSequences(open, openChar, closeChar) + triggerChars := s.triggerCharacters() + // 1) Inspect LSP completion context if present + if p.Context != nil </span><span class="cov6" title="11">{ + var ctx struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter,omitempty"` + } + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov6" title="10">{ + _ = json.Unmarshal(raw, &ctx) + }</span> else<span class="cov1" title="1"> { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + }</span> + // If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'), + // do not treat as a trigger source. + <span class="cov6" title="11">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov2" title="2">{ + return false + }</span> + // TriggerKind 1 = Invoked (manual). Always allow manual invoke. + <span class="cov6" title="9">if ctx.TriggerKind == 1 </span><span class="cov4" title="5">{ + return true + }</span> + // TriggerKind 2 is TriggerCharacter per LSP spec + <span class="cov4" title="4">if ctx.TriggerKind == 2 </span><span class="cov3" title="3">{ + if ctx.TriggerCharacter != "" </span><span class="cov2" title="2">{ + for _, c := range triggerChars </span><span class="cov2" title="2">{ + if c == ctx.TriggerCharacter </span><span class="cov2" title="2">{ + return true + }</span> + } + <span class="cov0" title="0">return false</span> + } + // No character provided but reported as TriggerCharacter; be conservative + <span class="cov1" title="1">return false</span> + } + // For TriggerForIncomplete (3), require manual char check below + } + // 2) Fallback: check the character immediately prior to cursor + <span class="cov7" title="15">idx := p.Position.Character + if idx <= 0 || idx > len(current) </span><span class="cov0" title="0">{ + return false + }</span> + // Bare double-open should not trigger via fallback char either (only when configured) + <span class="cov7" title="15">if containsAny(current, doubleSeqs) && !hasDoubleOpenTrigger(current, open, openChar, closeChar) </span><span class="cov3" title="3">{ + return false + }</span> + <span class="cov6" title="12">ch := string(current[idx-1]) + for _, c := range triggerChars </span><span class="cov8" title="28">{ + if c == ch </span><span class="cov5" title="6">{ + return true + }</span> + } + <span class="cov5" title="6">return false</span> +} + +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string, detail string, sortPrefix string) []CompletionItem <span class="cov7" title="14">{ + te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) + rm := s.collectPromptRemovalEdits(p.TextDocument.URI) + label := labelForCompletion(cleaned, filter) + if strings.TrimSpace(detail) == "" </span><span class="cov0" title="0">{ + detail = "Hexai LLM completion" + }</span> + <span class="cov7" title="14">if sortPrefix == "" </span><span class="cov0" title="0">{ + sortPrefix = "0000" + }</span> + <span class="cov7" title="14">return []CompletionItem{{ + Label: label, + Kind: 1, + Detail: detail, + InsertTextFormat: 1, + FilterText: strings.TrimLeft(filter, " \t"), + TextEdit: te, + AdditionalTextEdits: rm, + SortText: sortPrefix, + Documentation: docStr, + }}</span> +} + +func containsAny(haystack string, seqs []string) bool <span class="cov8" title="26">{ + for _, seq := range seqs </span><span class="cov10" title="51">{ + if seq == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov10" title="51">if strings.Contains(haystack, seq) </span><span class="cov4" title="5">{ + return true + }</span> + } + <span class="cov7" title="21">return false</span> +} + +// 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 <span class="cov1" title="1">{ + return []CompletionItem{{ + Label: "hexai-complete", + Kind: 1, + Detail: "dummy completion", + InsertText: "hexai", + SortText: "9999", + Documentation: docStr, + }} +}</span> +</pre> + + <pre class="file" id="file29" style="display: none">// 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) <span class="cov4" title="5">{ + var p CodeActionParams + if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, []CodeAction{}, nil) + }</span> + <span class="cov0" title="0">return</span> + } + // Skip code actions for gitignored / extra-pattern-ignored files + <span class="cov4" title="5">if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "code action skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI) + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, []CodeAction{}, nil) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov4" title="5">d := s.getDocument(p.TextDocument.URI) + if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil </span><span class="cov2" title="2">{ + if len(req.ID) != 0 </span><span class="cov2" title="2">{ + s.reply(req.ID, []CodeAction{}, nil) + }</span> + <span class="cov2" title="2">return</span> + } + <span class="cov3" title="3">sel := extractRangeText(d, p.Range) + + actions := make([]CodeAction, 0, 8) + if a := s.buildRewriteCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov3" title="3">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + actions = append(actions, *a) + }</span> + <span class="cov3" title="3">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + actions = append(actions, *a) + }</span> + <span class="cov3" title="3">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov3" title="3">{ + actions = append(actions, *a) + }</span> + <span class="cov3" title="3">if a := s.buildSimplifyCodeAction(p, sel); a != nil </span><span class="cov2" title="2">{ + actions = append(actions, *a) + }</span> + // Custom actions from config + <span class="cov3" title="3">s.appendCustomActions(&actions, p, sel) + if len(req.ID) != 0 </span><span class="cov3" title="3">{ + s.reply(req.ID, actions, nil) + }</span> +} + +// appendCustomActions adds user-defined actions depending on scope and availability. +func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) <span class="cov3" title="3">{ + customs := s.customActions() + if len(customs) == 0 </span><span class="cov1" title="1">{ + return + }</span> + <span class="cov2" title="2">diags := s.diagnosticsInRange(p.Context, p.Range) + for _, ca := range customs </span><span class="cov3" title="4">{ + title := strings.TrimSpace(ca.Title) + if title == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov3" title="4">scope := strings.TrimSpace(strings.ToLower(ca.Scope)) + if scope == "diagnostics" </span><span class="cov2" title="2">{ + if len(diags) == 0 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov2" title="2">payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" </span><span class="cov1" title="1">{ + kind = "quickfix" + }</span> + <span class="cov2" title="2">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + continue</span> + } + // default: selection + <span class="cov2" title="2">if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ + continue</span> + } + <span class="cov1" title="1">payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" </span><span class="cov0" title="0">{ + kind = "refactor" + }</span> + <span class="cov1" title="1">*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})</span> + } +} + +func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="3">{ + if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov2" title="2">payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "simplify", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: simplify and improve", Kind: "refactor", Data: raw} + return &ca</span> +} + +func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="5">{ + if instr, cleaned := s.instructionFromSelection(sel); strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ + 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 + }</span> + <span class="cov3" title="4">return nil</span> +} + +func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="6">{ + diags := s.diagnosticsInRange(p.Context, p.Range) + if len(diags) == 0 </span><span class="cov2" title="2">{ + return nil + }</span> + <span class="cov3" title="4">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</span> +} + +func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov6" title="17">{ + if s.currentLLMClient() == nil || len(ca.Data) == 0 </span><span class="cov1" title="1">{ + return ca, false + }</span> + <span class="cov6" title="16">var payload struct { + Type string `json:"type"` + ID string `json:"id"` + 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 </span><span class="cov0" title="0">{ + return ca, false + }</span> + <span class="cov6" title="16">cfg := s.currentConfig() + switch payload.Type </span>{ + case "rewrite":<span class="cov3" title="4"> + sys := cfg.PromptCodeActionRewriteSystem + user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> + case "diagnostics":<span class="cov4" title="5"> + sys := cfg.PromptCodeActionDiagnosticsSystem + var b strings.Builder + for i, dgn := range payload.Diagnostics </span><span class="cov4" title="6">{ + if dgn.Source != "" </span><span class="cov0" title="0">{ + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + }</span> else<span class="cov4" title="6"> { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + }</span> + } + <span class="cov4" title="5">diagList := b.String() + user := renderTemplate(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 22*time.Second)</span> + case "document":<span class="cov3" title="3"> + sys := cfg.PromptCodeActionDocumentSystem + user := renderTemplate(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> + case "go_test":<span class="cov0" title="0"> + if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok </span><span class="cov0" title="0">{ + ca.Edit = &edit + ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} + s.deferShowDocument(jumpURI, jumpRange) + return ca, true + }</span> + case "simplify":<span class="cov0" title="0"> + sys := cfg.PromptCodeActionRewriteSystem + user := renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection}) + return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> + case "custom":<span class="cov3" title="4"> + var action *CustomAction + for _, caDef := range s.customActions() </span><span class="cov4" title="5">{ + if caDef.ID == payload.ID </span><span class="cov3" title="4">{ + action = &caDef + break</span> + } + } + <span class="cov3" title="4">if action == nil </span><span class="cov0" title="0">{ + return ca, false + }</span> + <span class="cov3" title="4">var sys, user string + if strings.TrimSpace(action.User) != "" </span><span class="cov1" title="1">{ + if strings.TrimSpace(action.System) != "" </span><span class="cov0" title="0">{ + sys = action.System + }</span> else<span class="cov1" title="1"> { + sys = cfg.PromptCodeActionRewriteSystem + }</span> + <span class="cov1" title="1">var diagList string + if len(payload.Diagnostics) > 0 </span><span class="cov1" title="1">{ + var b strings.Builder + for _, d := range payload.Diagnostics </span><span class="cov1" title="1">{ + fmt.Fprintf(&b, "%s\n", d.Message) + }</span> + <span class="cov1" title="1">diagList = b.String()</span> + } + <span class="cov1" title="1">user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": strings.TrimSpace(diagList)})</span> + } else<span class="cov3" title="3"> { + sys = cfg.PromptCodeActionRewriteSystem + user = renderTemplate(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection}) + }</span> + <span class="cov3" title="4">return s.completeCodeAction(ca, payload.URI, payload.Range, sys, user, 20*time.Second)</span> + } + <span class="cov0" title="0">return ca, false</span> +} + +func (s *Server) completeCodeAction(ca CodeAction, uri string, rng Range, sys, user string, timeout time.Duration) (CodeAction, bool) <span class="cov6" title="16">{ + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + spec := s.buildRequestSpec(surfaceCodeAction) + if text, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov6" title="15">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov6" title="14">{ + edit := WorkspaceEdit{Changes: map[string][]TextEdit{uri: {{Range: rng, NewText: out}}}} + ca.Edit = &edit + return ca, true + }</span> + } else<span class="cov1" title="1"> { + logging.Logf("lsp ", "codeAction llm error: %v", err) + }</span> + <span class="cov2" title="2">return ca, false</span> +} + +func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title="2">{ + var ca CodeAction + if err := json.Unmarshal(req.Params, &ca); err != nil </span><span class="cov0" title="0">{ + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, ca, nil) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov2" title="2">if resolved, ok := s.resolveCodeAction(ca); ok </span><span class="cov2" title="2">{ + s.reply(req.ID, resolved, nil) + return + }</span> + <span class="cov0" title="0">s.reply(req.ID, ca, nil)</span> +} + +// 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 <span class="cov5" title="11">{ + if len(ctxRaw) == 0 </span><span class="cov3" title="3">{ + return nil + }</span> + <span class="cov5" title="8">var ctx CodeActionContext + if err := json.Unmarshal(ctxRaw, &ctx); err != nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov5" title="8">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov5" title="8">out := make([]Diagnostic, 0, len(ctx.Diagnostics)) + for _, d := range ctx.Diagnostics </span><span class="cov5" title="11">{ + if rangesOverlap(d.Range, sel) </span><span class="cov5" title="8">{ + out = append(out, d) + }</span> + } + <span class="cov5" title="8">return out</span> +} + +// rangesOverlap reports whether two LSP ranges overlap at all. +func rangesOverlap(a, b Range) bool <span class="cov6" title="14">{ + // Normalize ordering + if greaterPos(a.Start, a.End) </span><span class="cov3" title="4">{ + a.Start, a.End = a.End, a.Start + }</span> + <span class="cov6" title="14">if greaterPos(b.Start, b.End) </span><span class="cov0" title="0">{ + b.Start, b.End = b.End, b.Start + }</span> + // a ends before b starts + <span class="cov6" title="14">if lessPos(a.End, b.Start) </span><span class="cov3" title="3">{ + return false + }</span> + // b ends before a starts + <span class="cov5" title="11">if lessPos(b.End, a.Start) </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov5" title="10">return true</span> +} + +func lessPos(p, q Position) bool <span class="cov7" title="27">{ + if p.Line != q.Line </span><span class="cov6" title="18">{ + return p.Line < q.Line + }</span> + <span class="cov5" title="9">return p.Character < q.Character</span> +} + +func greaterPos(p, q Position) bool <span class="cov7" title="30">{ + if p.Line != q.Line </span><span class="cov6" title="15">{ + return p.Line > q.Line + }</span> + <span class="cov6" title="15">return p.Character > q.Character</span> +} + +// --- Go unit test code action --- + +func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov4" title="5">{ + uri := p.TextDocument.URI + if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") </span><span class="cov0" title="0">{ + return nil + }</span> + // Skip if already a _test.go file + <span class="cov4" title="5">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov1" title="1">{ + return nil + }</span> + // Heuristic: only offer when a function context is found above the cursor + <span class="cov3" title="4">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) + if !strings.Contains(funcCtx, "func ") </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov3" title="4">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</span> +} + +// buildDocumentCodeAction offers to document the selected code by injecting comments. +func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="4">{ + if s.currentLLMClient() == nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov3" title="4">if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov3" title="3">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</span> +} + +func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) <span class="cov3" title="3">{ + path := strings.TrimPrefix(uri, "file://") + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + // Load source text + <span class="cov3" title="3">_, lines := s.loadFileText(uri) + if len(lines) == 0 </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + <span class="cov3" title="3">pkg := parseGoPackageName(lines) + fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) + if fnStart < 0 || fnEnd < fnStart </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + <span class="cov3" title="3">funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") + testFunc := s.generateGoTestFunction(funcCode) + if strings.TrimSpace(testFunc) == "" </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + // Determine test file target + <span class="cov3" title="3">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) </span><span class="cov1" title="1">{ + // 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 </span><span class="cov1" title="1">{ + lineIdx = len(tLines) - 1 + col = len(tLines[lineIdx]) + }</span> + <span class="cov1" title="1">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")) </span><span class="cov0" title="0">{ + b.WriteString("\n\n") + }</span> + <span class="cov1" title="1">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") </span><span class="cov0" title="0">{ + prefixNL = 2 + }</span> + <span class="cov1" title="1">startLine := lineIdx + prefixNL + // If we inserted with two newlines and last line wasn't blank, first newline moves to next line + if prefixNL > 0 </span><span class="cov0" title="0">{ + startLine = lineIdx + prefixNL + }</span> + <span class="cov1" title="1">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + return we, testURI, jump, true</span> + } + // Create new file content + <span class="cov2" title="2">var content strings.Builder + if pkg == "" </span><span class="cov0" title="0">{ + pkg = filepath.Base(filepath.Dir(path)) + }</span> + <span class="cov2" title="2">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 </span><span class="cov1" title="1">{ + before := pre[:idx] + startLine = strings.Count(before, "\n") + }</span> + <span class="cov2" title="2">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + return we, testURI, jump, true</span> +} + +// loadFileText returns the file content and lines. It prefers the open document; otherwise reads from disk. +func (s *Server) loadFileText(uri string) (string, []string) <span class="cov3" title="4">{ + if d := s.getDocument(uri); d != nil </span><span class="cov2" title="2">{ + return d.text, append([]string{}, d.lines...) + }</span> + <span class="cov2" title="2">path := strings.TrimPrefix(uri, "file://") + b, err := os.ReadFile(path) + if err != nil </span><span class="cov0" title="0">{ + return "", nil + }</span> + <span class="cov2" title="2">txt := string(b) + return txt, splitLines(txt)</span> +} + +func fileExists(path string) bool <span class="cov3" title="3">{ + if _, err := os.Stat(path); err == nil </span><span class="cov1" title="1">{ + return true + }</span> + <span class="cov2" title="2">return false</span> +} + +// parseGoPackageName returns the package name from file lines, or empty if not found. +func parseGoPackageName(lines []string) string <span class="cov4" title="5">{ + for _, ln := range lines </span><span class="cov4" title="6">{ + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "package ") </span><span class="cov3" title="4">{ + name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) + // strip inline comments + if i := strings.Index(name, " "); i >= 0 </span><span class="cov1" title="1">{ + name = name[:i] + }</span> + <span class="cov3" title="4">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ + name = name[:i] + }</span> + <span class="cov3" title="4">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ + name = strings.TrimSpace(name[:i]) + }</span> + <span class="cov3" title="4">return name</span> + } + } + <span class="cov1" title="1">return ""</span> +} + +// findGoFunctionAtLine finds the function enclosing or preceding line idx. Returns start and end line indexes. +func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov3" title="4">{ + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov3" title="4">if idx >= len(lines) </span><span class="cov0" title="0">{ + idx = len(lines) - 1 + }</span> + // find signature start + <span class="cov3" title="4">start := -1 + for i := idx; i >= 0; i-- </span><span class="cov3" title="4">{ + if strings.Contains(lines[i], "func ") </span><span class="cov3" title="4">{ + start = i + break</span> + } + <span class="cov0" title="0">if strings.Contains(lines[i], "}") </span><span class="cov0" title="0">{ + break</span> + } + } + <span class="cov3" title="4">if start == -1 </span><span class="cov0" title="0">{ + return -1, -1 + }</span> + // find first '{' + <span class="cov3" title="4">depth := 0 + seenOpen := false + for i := start; i < len(lines); i++ </span><span class="cov4" title="5">{ + ln := lines[i] + for j := 0; j < len(ln); j++ </span><span class="cov10" title="106">{ + switch ln[j] </span>{ + case '{':<span class="cov3" title="3"> + depth++ + seenOpen = true</span> + case '}':<span class="cov3" title="3"> + if depth > 0 </span><span class="cov3" title="3">{ + depth-- + }</span> + <span class="cov3" title="3">if seenOpen && depth == 0 </span><span class="cov3" title="3">{ + return start, i + }</span> + } + } + } + // if never saw '{', assume single-line prototype; return that line + <span class="cov1" title="1">if !seenOpen </span><span class="cov1" title="1">{ + return start, start + }</span> + <span class="cov0" title="0">return start, -1</span> +} + +// generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. +func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov3" title="4">{ + spec := s.buildRequestSpec(surfaceCodeAction) + cfg := s.currentConfig() + sys := cfg.PromptCodeActionGoTestSystem + user := renderTemplate(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) + ctx, cancel := context.WithTimeout(context.Background(), 18*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + if out, err := s.chatWithStats(ctx, surfaceCodeAction, spec, messages); err == nil </span><span class="cov3" title="4">{ + cleaned := strings.TrimSpace(stripCodeFences(out)) + if cleaned != "" </span><span class="cov3" title="4">{ + return cleaned + }</span> + } else<span class="cov0" title="0"> { + logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + }</span> + // Fallback stub + <span class="cov0" title="0">name := deriveGoFuncName(funcCode) + if name == "" </span><span class="cov0" title="0">{ + name = "Function" + }</span> + <span class="cov0" title="0">return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)</span> +} + +// deriveGoFuncName extracts function or method name from code. +func deriveGoFuncName(code string) string <span class="cov2" title="2">{ + // look for line starting with func + line := firstLine(code) + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "func ") </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov2" title="2">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + // method receiver + if strings.HasPrefix(rest, "(") </span><span class="cov1" title="1">{ + // find ")" + if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov1" title="1">{ + rest = strings.TrimSpace(rest[i+1:]) + }</span> + } + // now rest should start with Name( + <span class="cov2" title="2">if i := strings.Index(rest, "("); i > 0 </span><span class="cov2" title="2">{ + return strings.TrimSpace(rest[:i]) + }</span> + <span class="cov0" title="0">return ""</span> +} + +func exportName(name string) string <span class="cov0" title="0">{ + if name == "" </span><span class="cov0" title="0">{ + return name + }</span> + <span class="cov0" title="0">r := []rune(name) + if r[0] >= 'a' && r[0] <= 'z' </span><span class="cov0" title="0">{ + r[0] = r[0] - ('a' - 'A') + }</span> + <span class="cov0" title="0">return string(r)</span> +} +</pre> + + <pre class="file" id="file30" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" +) + +type completionPlan struct { + params CompletionParams + above string + current string + below string + funcCtx string + docStr string + hasExtra bool + extraText string + inlinePrompt bool + inParams bool + manualInvoke bool + cacheKey string +} + +func (s *Server) handleCompletion(req Request) <span class="cov3" title="3">{ + if s.completionDisabled() </span><span class="cov1" title="1">{ + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil) + return + }</span> + <span class="cov2" title="2">var p CompletionParams + var docStr string + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov2" title="2">{ + // Skip completion for gitignored / extra-pattern-ignored files + if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "completion skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI) + if s.ignoreLSPNotifyEnabled() </span><span class="cov0" title="0">{ + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: []CompletionItem{ + {Label: "[hexai] file ignored", Detail: reason}, + }}, nil) + }</span> else<span class="cov0" title="0"> { + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil) + }</span> + <span class="cov0" title="0">return</span> + } + // Log trigger information for every completion request from client + <span class="cov2" title="2">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 </span><span class="cov0" title="0">{ + s.logCompletionContext(p, above, current, below, funcCtx) + }</span> + <span class="cov2" title="2">if s.llmClient != nil </span><span class="cov2" title="2">{ + newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) + extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) + items, ok, incomplete := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) + if ok </span><span class="cov2" title="2">{ + s.reply(req.ID, CompletionList{IsIncomplete: incomplete, Items: items}, nil) + return + }</span> + } + } + <span class="cov0" title="0">items := s.fallbackCompletionItems(docStr) + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)</span> +} + +// 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) <span class="cov3" title="3">{ + if p.Context == nil </span><span class="cov0" title="0">{ + return 0, "" + }</span> + <span class="cov3" title="3">var ctx struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter,omitempty"` + } + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov1" title="1">{ + _ = json.Unmarshal(raw, &ctx) + }</span> else<span class="cov2" title="2"> { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + }</span> + <span class="cov3" title="3">return ctx.TriggerKind, ctx.TriggerCharacter</span> +} + +// --- completion helpers --- + +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov3" title="3">{ + 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)) +}</span> + +func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) <span class="cov1" title="1">{ + 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)) +}</span> + +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool, bool) <span class="cov8" title="19">{ + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + var cancelOnce sync.Once + end := func() </span><span class="cov8" title="19">{ cancelOnce.Do(cancel) }</span> + + <span class="cov8" title="19">plan, items, handled := s.prepareCompletionPlan(p, above, current, below, funcCtx, docStr, hasExtra, extraText) + if handled </span><span class="cov6" title="8">{ + end() + return items, true, false + }</span> + <span class="cov6" title="11">specs := s.buildRequestSpecs(surfaceCompletion) + if len(specs) == 0 </span><span class="cov0" title="0">{ + end() + return nil, false, false + }</span> + <span class="cov6" title="11">type jobResult struct { + items []CompletionItem + ok bool + } + results := make(chan jobResult, len(specs)) + var wg sync.WaitGroup + started := 0 + s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ + end() + close(results) + return nil, false, false + }</span> + <span class="cov6" title="11">for _, spec := range specs </span><span class="cov6" title="11">{ + spec := spec + client := s.clientFor(spec) + if client == nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov6" title="11">started++ + wg.Add(1) + go func(idx int, spec requestSpec, client llm.Client) </span><span class="cov6" title="11">{ + defer wg.Done() + items, ok := s.runCompletionForSpec(ctx, plan, spec, client) + results <- jobResult{items: items, ok: ok} + }</span>(spec.index, spec, client) + } + + <span class="cov6" title="11">if started == 0 </span><span class="cov0" title="0">{ + end() + close(results) + return nil, false, false + }</span> + + <span class="cov6" title="11">go func() </span><span class="cov6" title="11">{ + wg.Wait() + close(results) + }</span>() + + <span class="cov6" title="11">if started == 1 </span><span class="cov6" title="11">{ + res := <-results + if !res.ok || len(res.items) == 0 </span><span class="cov0" title="0">{ + end() + return nil, false, false + }</span> + <span class="cov6" title="11">end() + return res.items, true, false</span> + } + + <span class="cov0" title="0">waitAll := s.completionWaitAll() + if waitAll </span><span class="cov0" title="0">{ + // Wait for all backends, return combined results + defer end() + combined := make([]CompletionItem, 0) + for res := range results </span><span class="cov0" title="0">{ + if !res.ok || len(res.items) == 0 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">combined = append(combined, res.items...)</span> + } + <span class="cov0" title="0">if len(combined) == 0 </span><span class="cov0" title="0">{ + return nil, false, false + }</span> + <span class="cov0" title="0">return combined, true, false</span> + } + + // Return first result immediately, store combined for later + <span class="cov0" title="0">firstCh := make(chan []CompletionItem, 1) + go func(planKey string) </span><span class="cov0" title="0">{ + defer end() + combined := make([]CompletionItem, 0) + firstSent := false + for res := range results </span><span class="cov0" title="0">{ + if !res.ok || len(res.items) == 0 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">combined = append(combined, res.items...) + if !firstSent </span><span class="cov0" title="0">{ + first := make([]CompletionItem, len(res.items)) + copy(first, res.items) + firstCh <- first + firstSent = true + }</span> + } + <span class="cov0" title="0">if !firstSent </span><span class="cov0" title="0">{ + close(firstCh) + return + }</span> + <span class="cov0" title="0">s.storePendingCompletion(planKey, combined) + close(firstCh)</span> + }(plan.cacheKey) + + <span class="cov0" title="0">firstItems, ok := <-firstCh + if !ok || len(firstItems) == 0 </span><span class="cov0" title="0">{ + end() + return nil, false, false + }</span> + <span class="cov0" title="0">return firstItems, true, true</span> +} + +func (s *Server) prepareCompletionPlan(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) (completionPlan, []CompletionItem, bool) <span class="cov8" title="19">{ + plan := completionPlan{ + params: p, + above: above, + current: current, + below: below, + funcCtx: funcCtx, + docStr: docStr, + hasExtra: hasExtra, + extraText: extraText, + } + openStr, _, openChar, closeChar := s.inlineMarkers() + plan.inlinePrompt = lineHasInlinePrompt(current, openStr, openChar, closeChar) + if !plan.inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov6" title="8">{ + 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 plan, []CompletionItem{}, true + }</span> + <span class="cov6" title="11">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ + return plan, []CompletionItem{}, true + }</span> + <span class="cov6" title="11">plan.inParams = inParamList(current, p.Position.Character) + plan.manualInvoke = parseManualInvoke(p.Context) + plan.cacheKey = s.completionCacheKey(p, above, current, below, funcCtx, plan.inParams, hasExtra, extraText) + if pending := s.takePendingCompletion(plan.cacheKey); len(pending) > 0 </span><span class="cov0" title="0">{ + return plan, pending, true + }</span> + <span class="cov6" title="11">if isBareDoubleOpen(current, openStr, openChar, closeChar) || isBareDoubleOpen(below, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ + 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 plan, []CompletionItem{}, true + }</span> + <span class="cov6" title="11">if !plan.inParams && !s.prefixHeuristicAllows(plan.inlinePrompt, current, p, plan.manualInvoke) </span><span class="cov0" title="0">{ + 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 plan, []CompletionItem{}, true + }</span> + <span class="cov6" title="11">return plan, nil, false</span> +} + +func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client) ([]CompletionItem, bool) <span class="cov6" title="11">{ + sortPrefix := fmt.Sprintf("%04d", spec.index) + modelKey := spec.effectiveModel(client.DefaultModel()) + providerKey := spec.provider + if providerKey == "" </span><span class="cov0" title="0">{ + providerKey = canonicalProvider(client.Name()) + }</span> + <span class="cov6" title="11">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelKey + if cached, ok := s.completionCacheGet(cacheKey); ok && strings.TrimSpace(cached) != "" </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", + plan.params.TextDocument.URI, plan.params.Position.Line, plan.params.Position.Character, + logging.AnsiGreen, logging.PreviewForLog(cached), logging.AnsiBase) + detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelKey) + items := s.makeCompletionItems(cached, plan.inParams, plan.current, plan.params, plan.docStr, detail, sortPrefix) + return items, true + }</span> + <span class="cov6" title="10">if items, ok := s.tryProviderNativeCompletion(ctx, plan, spec, client, sortPrefix); ok </span><span class="cov1" title="1">{ + return items, true + }</span> + <span class="cov6" title="9">return s.executeChatCompletion(ctx, plan, spec, client, sortPrefix)</span> +} + +func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov6" title="9">{ + messages := s.buildCompletionMessages(plan.inlinePrompt, plan.hasExtra, plan.extraText, plan.inParams, plan.params, plan.above, plan.current, plan.below, plan.funcCtx) + sentSize := 0 + for _, m := range messages </span><span class="cov8" title="18">{ + sentSize += len(m.Content) + }</span> + <span class="cov6" title="9">s.incSentCounters(sentSize) + text, err := client.Chat(ctx, messages, spec.options...) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "llm completion error: %v", err) + s.logLLMStats("") + return nil, false + }</span> + <span class="cov6" title="9">s.incRecvCounters(len(text)) + modelUsed := spec.effectiveModel(client.DefaultModel()) + _ = stats.Update(ctx, client.Name(), modelUsed, sentSize, len(text)) + s.logLLMStats(modelUsed) + trimmed := strings.TrimSpace(text) + cleaned := s.postProcessCompletion(trimmed, plan.current[:plan.params.Position.Character], plan.current) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov6" title="9">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) + providerKey := spec.provider + if providerKey == "" </span><span class="cov0" title="0">{ + providerKey = canonicalProvider(client.Name()) + }</span> + <span class="cov6" title="9">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed + s.completionCachePut(cacheKey, cleaned) + items := s.makeCompletionItems(cleaned, plan.inParams, plan.current, plan.params, plan.docStr, detail, sortPrefix) + return items, true</span> +} + +// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. +func parseManualInvoke(ctx any) bool <span class="cov7" title="12">{ + if ctx == nil </span><span class="cov4" title="5">{ + return false + }</span> + <span class="cov5" title="7">var c struct { + TriggerKind int `json:"triggerKind"` + } + if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov4" title="5">{ + _ = json.Unmarshal(raw, &c) + }</span> else<span class="cov2" title="2"> { + b, _ := json.Marshal(ctx) + _ = json.Unmarshal(b, &c) + }</span> + <span class="cov5" title="7">return c.TriggerKind == 1</span> +} + +// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov7" title="16">{ + t := strings.TrimRight(current, " \t") + suffix, prefixes, _ := s.chatConfig() + if suffix == "" </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov7" title="15">if strings.HasSuffix(t, suffix) </span><span class="cov4" title="5">{ + if len(t) < len(suffix)+1 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov4" title="5">prev := string(t[len(t)-len(suffix)-1]) + for _, pf := range prefixes </span><span class="cov7" title="14">{ + if prev == pf </span><span class="cov2" title="2">{ + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + }</span> + } + } + <span class="cov7" title="13">return false</span> +} + +// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="16">{ + // 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) </span><span class="cov0" title="0">{ + idx = len(current) + }</span> + <span class="cov7" title="16">allowNoPrefix := inlinePrompt + if idx > 0 </span><span class="cov7" title="14">{ + ch := current[idx-1] + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="5">{ + allowNoPrefix = true + }</span> + } + <span class="cov7" title="16">if allowNoPrefix </span><span class="cov6" title="8">{ + return true + }</span> + // Walk left over whitespace + <span class="cov6" title="8">j := idx + for j > 0 </span><span class="cov9" title="27">{ + c := current[j-1] + if c == ' ' || c == '\t' </span><span class="cov8" title="20">{ + j-- + continue</span> + } + <span class="cov5" title="7">break</span> + } + <span class="cov6" title="8">start := computeWordStart(current, j) + min := 1 + if manualInvoke </span><span class="cov4" title="5">{ + if v := s.manualInvokeMinPrefix(); v >= 0 </span><span class="cov4" title="5">{ + min = v + }</span> + } + <span class="cov6" title="8">return j-start >= min</span> +} + +// tryProviderNativeCompletion attempts provider-native completion and returns items when successful. +func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) <span class="cov7" title="13">{ + cc, ok := client.(llm.CodeCompleter) + if !ok </span><span class="cov5" title="7">{ + return nil, false + }</span> + <span class="cov5" title="6">current := plan.current + p := plan.params + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + path := strings.TrimPrefix(p.TextDocument.URI, "file://") + cfg := s.currentConfig() + openStr, _, openChar, closeChar := s.inlineMarkers() + prompt := renderTemplate(cfg.PromptNativeCompletion, map[string]string{ + "path": path, + "before": before, + }) + provider := spec.provider + if provider == "" </span><span class="cov0" title="0">{ + provider = canonicalProvider(cfg.Provider) + }</span> + <span class="cov5" title="6">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path) + ctx2, cancel2 := context.WithTimeout(ctx, 15*time.Second) + defer cancel2() + sentBytes := len(prompt) + len(after) + modelUsed := spec.effectiveModel(client.DefaultModel()) + tempVal := 0.0 + if val, ok := chooseSurfaceTemperature(surfaceCompletion, cfg, spec.entry, provider, modelUsed); ok </span><span class="cov0" title="0">{ + tempVal = val + }</span> + <span class="cov5" title="6">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, "", tempVal) + if err != nil || len(suggestions) == 0 </span><span class="cov2" title="2">{ + if err != nil </span><span class="cov2" title="2">{ + logging.Logf("lsp ", "completion path=codex error=%v (falling back)", err) + }</span> + <span class="cov2" title="2">return nil, false</span> + } + <span class="cov4" title="4">s.incSentCounters(sentBytes) + s.incRecvCounters(len(suggestions[0])) + _ = stats.Update(ctx2, client.Name(), modelUsed, sentBytes, len(suggestions[0])) + s.logLLMStats(modelUsed) + cleaned := strings.TrimSpace(suggestions[0]) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="4">cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="4">cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="4">if strings.TrimSpace(cleaned) != "" && hasDoubleOpenTrigger(current, openStr, openChar, closeChar) </span><span class="cov1" title="1">{ + indent := leadingIndent(current) + if indent != "" </span><span class="cov1" title="1">{ + cleaned = applyIndent(indent, cleaned) + }</span> + } + <span class="cov4" title="4">if strings.TrimSpace(cleaned) == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov4" title="4">detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed) + providerKey := provider + if providerKey == "" </span><span class="cov0" title="0">{ + providerKey = canonicalProvider(client.Name()) + }</span> + <span class="cov4" title="4">cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed + s.completionCachePut(cacheKey, cleaned) + items := s.makeCompletionItems(cleaned, plan.inParams, current, p, plan.docStr, detail, sortPrefix) + return items, true</span> +} + +// 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) <span class="cov10" title="40">{ + d := s.completionDebounce() + if d <= 0 </span><span class="cov9" title="38">{ + return + }</span> + <span class="cov2" title="2">for </span><span class="cov4" title="4">{ + s.mu.RLock() + last := s.lastInput + s.mu.RUnlock() + if last.IsZero() </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov4" title="4">since := time.Since(last) + if since >= d </span><span class="cov2" title="2">{ + return + }</span> + <span class="cov2" title="2">rem := d - since + timer := time.NewTimer(rem) + select </span>{ + case <-ctx.Done():<span class="cov0" title="0"> + timer.Stop() + return</span> + case <-timer.C:<span class="cov2" title="2"></span> + // 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 <span class="cov10" title="40">{ + interval := s.completionThrottle() + if interval <= 0 </span><span class="cov9" title="37">{ + return true + }</span> + <span class="cov3" title="3">var wait time.Duration + for </span><span class="cov4" title="5">{ + s.mu.Lock() + next := s.lastLLMCall.Add(interval) + now := time.Now() + if now.Before(next) </span><span class="cov2" title="2">{ + wait = next.Sub(now) + s.mu.Unlock() + timer := time.NewTimer(wait) + select </span>{ + case <-ctx.Done():<span class="cov0" title="0"> + timer.Stop() + return false</span> + case <-timer.C:<span class="cov2" title="2"> + // try again to set the next call time + continue</span> + } + } + // we are allowed to proceed now; record this call as the latest + <span class="cov3" title="3">s.lastLLMCall = now + s.mu.Unlock() + return true</span> + } +} + +// 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 <span class="cov7" title="15">{ + vars := map[string]string{ + "file": p.TextDocument.URI, + "function": funcCtx, + "above": above, + "current": current, + "below": below, + "char": fmt.Sprintf("%d", p.Position.Character), + } + cfg := s.currentConfig() + sys := cfg.PromptCompletionSystemGeneral + userTpl := cfg.PromptCompletionUserGeneral + if inParams </span><span class="cov2" title="2">{ + sys = cfg.PromptCompletionSystemParams + userTpl = cfg.PromptCompletionUserParams + }</span> + <span class="cov7" title="15">if inlinePrompt && strings.TrimSpace(cfg.PromptCompletionSystemInline) != "" </span><span class="cov2" title="2">{ + sys = cfg.PromptCompletionSystemInline + }</span> + <span class="cov7" title="15">user := renderTemplate(userTpl, vars) + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + if hasExtra && strings.TrimSpace(extraText) != "" </span><span class="cov1" title="1">{ + extra := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extraText}) + if strings.TrimSpace(extra) == "" </span><span class="cov0" title="0">{ + extra = extraText + }</span> + <span class="cov1" title="1">messages = append(messages, llm.Message{Role: "user", Content: extra})</span> + } + <span class="cov7" title="15">return messages</span> +} + +// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov7" title="12">{ + cleaned := stripCodeFences(text) + if cleaned != "" && strings.ContainsRune(cleaned, '`') </span><span class="cov0" title="0">{ + if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" </span><span class="cov0" title="0">{ + cleaned = inline + }</span> + } + <span class="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ + cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) + }</span> + <span class="cov7" title="12">if cleaned != "" </span><span class="cov7" title="12">{ + cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) + }</span> + <span class="cov7" title="12">openStr, _, openChar, closeChar := s.inlineMarkers() + if cleaned != "" && hasDoubleOpenTrigger(currentLine, openStr, openChar, closeChar) </span><span class="cov2" title="2">{ + if indent := leadingIndent(currentLine); indent != "" </span><span class="cov1" title="1">{ + cleaned = applyIndent(indent, cleaned) + }</span> + } + <span class="cov7" title="12">return cleaned</span> +} +</pre> + + <pre class="file" id="file31" style="display: none">// 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" +) + +func (s *Server) handleDidOpen(req Request) <span class="cov1" title="1">{ + var p DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ + s.setDocument(p.TextDocument.URI, p.TextDocument.Text) + s.markActivity() + // Log when an ignored file is opened (document still stored for editor sync) + if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "file opened (ignored): %s (%s)", p.TextDocument.URI, reason) + }</span> + } +} + +func (s *Server) handleDidChange(req Request) <span class="cov1" title="1">{ + var p DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ + if len(p.ContentChanges) > 0 </span><span class="cov1" title="1">{ + s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) + }</span> + <span class="cov1" title="1">s.markActivity() + // Detect in-editor chat trigger lines and respond inline. + s.detectAndHandleChat(p.TextDocument.URI)</span> + } +} + +func (s *Server) handleDidClose(req Request) <span class="cov1" title="1">{ + var p DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov1" title="1">{ + s.deleteDocument(p.TextDocument.URI) + s.markActivity() + }</span> +} + +// 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) <span class="cov6" title="8">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov4" title="4">{ + return "", "" + }</span> + // Clamp indices + <span class="cov4" title="4">line := pos.Line + if line < 0 </span><span class="cov0" title="0">{ + line = 0 + }</span> + <span class="cov4" title="4">if line >= len(d.lines) </span><span class="cov1" title="1">{ + line = len(d.lines) - 1 + }</span> + <span class="cov4" title="4">col := pos.Character + if col < 0 </span><span class="cov0" title="0">{ + col = 0 + }</span> + <span class="cov4" title="4">if col > len(d.lines[line]) </span><span class="cov1" title="1">{ + col = len(d.lines[line]) + }</span> + // Build before + <span class="cov4" title="4">var b strings.Builder + for i := 0; i < line; i++ </span><span class="cov5" title="5">{ + b.WriteString(d.lines[i]) + b.WriteByte('\n') + }</span> + <span class="cov4" title="4">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++ </span><span class="cov4" title="4">{ + a.WriteByte('\n') + a.WriteString(d.lines[i]) + }</span> + <span class="cov4" title="4">return before, a.String()</span> +} + +// --- 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) <span class="cov7" title="11">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov7" title="11">suffix, prefixes, _ := s.chatConfig() + openStr, _, openChar, closeChar := s.inlineMarkers() + for i, raw := range d.lines </span><span class="cov9" title="23">{ + if lineHasInlinePrompt(raw, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ + if s.currentLLMClient() != nil </span><span class="cov0" title="0">{ + pos := Position{Line: i, Character: len(raw)} + go s.runInlinePrompt(uri, pos) + }</span> + <span class="cov0" title="0">continue</span> + } + // Find last non-space character index + <span class="cov9" title="23">j := len(raw) - 1 + for j >= 0 </span><span class="cov9" title="20">{ + if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ + j-- + continue</span> + } + <span class="cov9" title="20">break</span> + } + <span class="cov9" title="23">if j < 0 </span><span class="cov4" title="3">{ + continue</span> + } + // Check suffix and derive the prompt text before validating prefixes + <span class="cov9" title="20">if suffix == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov9" title="20">if string(raw[j]) != suffix </span><span class="cov7" title="10">{ + continue</span> + } + <span class="cov7" title="10">removeCount := len(suffix) + base := raw[:j+1-removeCount] + prompt := strings.TrimSpace(base) + if prompt == "" </span><span class="cov0" title="0">{ + continue</span> + } + // Slash commands (`/foo>`) do not require a prefix trigger. + <span class="cov7" title="10">isCommand := strings.HasPrefix(prompt, "/") + if !isCommand </span><span class="cov7" title="9">{ + // Require at least one char before suffix and that char must be in chatPrefixes + if j < 1 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov7" title="9">prev := string(raw[j-1]) + match := false + for _, pfx := range prefixes </span><span class="cov7" title="9">{ + if prev == pfx </span><span class="cov7" title="9">{ + match = true + break</span> + } + } + <span class="cov7" title="9">if !match </span><span class="cov0" title="0">{ + continue</span> + } + } + // Avoid double-answering: if the next non-empty line starts with '>' we skip. + <span class="cov7" title="10">k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov7" title="11">{ + k++ + }</span> + <span class="cov7" title="10">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov1" title="1">{ + continue</span> + } + <span class="cov7" title="9">lineIdx := i + lastIdx := j + if resp, ok := s.chatCommandResponse(uri, lineIdx, prompt); ok </span><span class="cov1" title="1">{ + msg := strings.TrimSpace(resp.message) + if msg != "" </span><span class="cov1" title="1">{ + s.applyChatEdits(uri, lineIdx, lastIdx, removeCount, "> "+msg) + }</span> + <span class="cov1" title="1">return</span> + } + <span class="cov6" title="8">go func(prompt string, remove int) </span><span class="cov6" title="8">{ + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + // Build messages with history and context_mode aware extras. + pos := Position{Line: lineIdx, Character: lastIdx + 1} + msgs := s.buildChatMessages(uri, pos, prompt) + spec := s.buildRequestSpec(surfaceChat) + client := s.clientFor(spec) + if client == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov6" title="8">modelUsed := spec.effectiveModel(client.DefaultModel()) + logging.Logf("lsp ", "chat llm=requesting model=%s", modelUsed) + text, err := s.chatWithStats(ctx, surfaceChat, spec, msgs) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "chat llm error: %v", err) + return + }</span> + <span class="cov6" title="8">out := strings.TrimSpace(stripCodeFences(text)) + if out == "" </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov6" title="8">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> + }(prompt, removeCount) + // Only handle one per change tick to avoid flooding + <span class="cov6" title="8">break</span> + } +} + +// 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) <span class="cov7" title="9">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov0" title="0">{ + return + }</span> + // 1) Delete the trailing punctuation (1 or 2 chars) + <span class="cov7" title="9">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)</span> +} + +func (s *Server) runInlinePrompt(uri string, pos Position) <span class="cov0" title="0">{ + if s.currentLLMClient() == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">d := s.getDocument(uri) + if d == nil || pos.Line < 0 || pos.Line >= len(d.lines) </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">line := d.lines[pos.Line] + openStr, _, openChar, closeChar := s.inlineMarkers() + if !lineHasInlinePrompt(line, openStr, openChar, closeChar) </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: pos.Line, Character: len(line)}} + p.Context = map[string]int{"triggerKind": 1} + above, current, below, funcCtx := s.lineContext(uri, p.Position) + docStr := s.buildDocString(p, above, current, below, funcCtx) + newFunc := s.isDefiningNewFunction(uri, p.Position) + extra, hasExtra := s.buildAdditionalContext(newFunc, uri, p.Position) + items, ok, _ := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, hasExtra, extra) + if !ok || len(items) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">s.applyInlineCompletion(uri, items[0])</span> +} + +func (s *Server) applyInlineCompletion(uri string, item CompletionItem) <span class="cov0" title="0">{ + var edits []TextEdit + if len(item.AdditionalTextEdits) > 0 </span><span class="cov0" title="0">{ + edits = append(edits, item.AdditionalTextEdits...) + }</span> + <span class="cov0" title="0">if item.TextEdit != nil </span><span class="cov0" title="0">{ + edits = append(edits, *item.TextEdit) + }</span> + <span class="cov0" title="0">if len(edits) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} + s.clientApplyEdit("Hexai: inline prompt", we)</span> +} + +// 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 <span class="cov7" title="9">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov0" title="0">{ + return []llm.Message{{Role: "user", Content: currentPrompt}} + }</span> + <span class="cov7" title="9">type pair struct{ q, a string } + pairs := []pair{} + i := lineIdx - 1 + for i >= 0 && len(pairs) < 3 </span><span class="cov6" title="7">{ + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov1" title="1">{ + i-- + }</span> + <span class="cov6" title="7">if i < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov6" title="7">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov5" title="5">{ + break</span> + } + <span class="cov2" title="2">var replyLines []string + for i >= 0 </span><span class="cov4" title="4">{ + line := strings.TrimSpace(d.lines[i]) + if strings.HasPrefix(line, ">") </span><span class="cov2" title="2">{ + replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) + i-- + continue</span> + } + <span class="cov2" title="2">break</span> + } + <span class="cov2" title="2">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ + i-- + }</span> + <span class="cov2" title="2">if i < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov2" title="2">q := strings.TrimSpace(d.lines[i]) + q = s.stripTrailingTrigger(q) + pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) + i--</span> + } + <span class="cov7" title="9">msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs </span><span class="cov2" title="2">{ + if strings.TrimSpace(p.q) != "" </span><span class="cov2" title="2">{ + msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) + }</span> + <span class="cov2" title="2">if strings.TrimSpace(p.a) != "" </span><span class="cov2" title="2">{ + msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) + }</span> + } + <span class="cov7" title="9">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + return msgs</span> +} + +// stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. +func (s *Server) stripTrailingTrigger(sx string) string <span class="cov9" title="21">{ + trim := strings.TrimRight(sx, " \t") + if len(trim) == 0 </span><span class="cov0" title="0">{ + return sx + }</span> + <span class="cov9" title="21">_, prefixes, suffixChar := s.chatConfig() + if len(trim) >= 2 && suffixChar != 0 && trim[len(trim)-1] == suffixChar </span><span class="cov7" title="9">{ + prev := string(trim[len(trim)-2]) + for _, pf := range prefixes </span><span class="cov10" title="27">{ + if prev == pf </span><span class="cov5" title="5">{ + return strings.TrimRight(trim[:len(trim)-1], " \t") + }</span> + } + } + <span class="cov8" title="16">last := trim[len(trim)-1] + switch last </span>{ + case '?', '!', ':':<span class="cov6" title="8"> + return strings.TrimRight(trim[:len(trim)-1], " \t")</span> + default:<span class="cov6" title="8"> + return sx</span> + } +} + +// buildChatMessages assembles the chat request messages using: +// - system from prompts.chat.system +// - rolling in-editor history up to current prompt +// - optional extra context per general.context_mode (window/full-file/new-func) +func (s *Server) buildChatMessages(uri string, pos Position, prompt string) []llm.Message <span class="cov6" title="8">{ + // Base system and history + cfg := s.currentConfig() + sys := cfg.PromptChatSystem + // Determine line index for history from position + lineIdx := pos.Line + history := s.buildChatHistory(uri, lineIdx, prompt) + // Start with system + msgs := []llm.Message{{Role: "system", Content: sys}} + // Optional additional context like completion path (insert before history so last remains the prompt) + newFunc := s.isDefiningNewFunction(uri, pos) + if extra, has := s.buildAdditionalContext(newFunc, uri, pos); has && strings.TrimSpace(extra) != "" </span><span class="cov4" title="3">{ + // Reuse completion's extra header template to avoid duplication + header := renderTemplate(cfg.PromptCompletionExtraHeader, map[string]string{"context": extra}) + if strings.TrimSpace(header) == "" </span><span class="cov0" title="0">{ + header = extra + }</span> + <span class="cov4" title="3">msgs = append(msgs, llm.Message{Role: "user", Content: header})</span> + } + // Then add history (which ends with the current prompt) + <span class="cov6" title="8">msgs = append(msgs, history...) + return msgs</span> +} + +// clientApplyEdit sends a workspace/applyEdit request to the client. +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov7" title="9">{ + 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) +}</span> + +// nextReqID returns a unique json.RawMessage id for server-initiated requests. +func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="12">{ + s.mu.Lock() + s.nextID++ + idNum := s.nextID + s.mu.Unlock() + b, _ := json.Marshal(idNum) + return b +}</span> + +// clientShowDocument asks the client to open/focus a document and select a range. +func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov4" title="3">{ + 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) +}</span> + +// 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) <span class="cov1" title="1">{ + go func() </span><span class="cov1" title="1">{ + time.Sleep(120 * time.Millisecond) + s.clientShowDocument(uri, &sel) + }</span>() +} +</pre> + + <pre class="file" id="file32" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). +package lsp + +import ( + "encoding/json" +) + +func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1">{ + var p ExecuteCommandParams + if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ + s.reply(req.ID, nil, nil) + return + }</span> + <span class="cov8" title="1">switch p.Command </span>{ + case "hexai.showDocument":<span class="cov8" title="1"> + if len(p.Arguments) >= 2 </span><span class="cov8" title="1">{ + 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 </span><span class="cov8" title="1">{ + _ = json.Unmarshal(b, &r) + }</span> + <span class="cov8" title="1">if uri != "" </span><span class="cov8" title="1">{ + s.clientShowDocument(uri, &r) + }</span> + } + <span class="cov8" title="1">s.reply(req.ID, nil, nil) + return</span> + default:<span class="cov0" title="0"> + // Unknown command; no-op + s.reply(req.ID, nil, nil) + return</span> + } +} +</pre> + + <pre class="file" id="file33" style="display: none">// Summary: Helpers for gitignore-aware file filtering in LSP handlers. +package lsp + +import ( + "net/url" + "strings" +) + +// isFileIgnored checks whether the file at the given LSP URI should be ignored. +// Returns false when no ignore checker is configured. +func (s *Server) isFileIgnored(uri string) (bool, string) <span class="cov10" title="14">{ + if s.ignoreChecker == nil </span><span class="cov8" title="9">{ + return false, "" + }</span> + <span class="cov6" title="5">absPath := uriToPath(uri) + if absPath == "" </span><span class="cov0" title="0">{ + return false, "" + }</span> + <span class="cov6" title="5">return s.ignoreChecker.IsIgnored(absPath)</span> +} + +// ignoreLSPNotifyEnabled returns whether to show "file ignored" completion items +// when a file is ignored. Reads from the IgnoreLSPNotify config field. +func (s *Server) ignoreLSPNotifyEnabled() bool <span class="cov4" title="3">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.IgnoreLSPNotify == nil || *s.cfg.IgnoreLSPNotify +}</span> + +// uriToPath converts a file:// URI to an absolute file path. +// Returns empty string for non-file URIs. +func uriToPath(uri string) string <span class="cov8" title="10">{ + if !strings.HasPrefix(uri, "file://") </span><span class="cov3" title="2">{ + return "" + }</span> + <span class="cov8" title="8">parsed, err := url.Parse(uri) + if err != nil </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov8" title="8">return parsed.Path</span> +} +</pre> + + <pre class="file" id="file34" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. +package lsp + +import ( + "os" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/logging" + tmx "codeberg.org/snonux/hexai/internal/tmux" +) + +func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ + client := s.currentLLMClient() + version := internal.Version + if client != nil </span><span class="cov0" title="0">{ + version = version + " [" + client.Name() + ":" + client.DefaultModel() + "]" + }</span> + <span class="cov10" title="2">res := InitializeResult{ + Capabilities: ServerCapabilities{ + TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + TriggerCharacters: s.triggerCharacters(), + }, + CodeActionProvider: CodeActionOptions{ResolveProvider: true}, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: version}, + } + s.reply(req.ID, res, nil)</span> +} + +func (s *Server) handleInitialized() <span class="cov1" title="1">{ + logging.Logf("lsp ", "client initialized") + // Emit an initial tmux heartbeat with provider/model + if client := s.currentLLMClient(); client != nil </span><span class="cov0" title="0">{ + _ = tmx.SetStatus(tmx.FormatLLMStartStatus(client.Name(), client.DefaultModel())) + }</span> +} + +func (s *Server) handleShutdown(req Request) <span class="cov1" title="1">{ + s.reply(req.ID, nil, nil) +}</span> + +func (s *Server) handleExit() <span class="cov0" title="0">{ + s.exited = true + os.Exit(0) +}</span> +</pre> + + <pre class="file" id="file35" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). +package lsp + +import ( + "context" + "fmt" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/textutil" + tmx "codeberg.org/snonux/hexai/internal/tmux" +) + +type surfaceKind string + +const ( + surfaceCompletion surfaceKind = "completion" + surfaceCodeAction surfaceKind = "code_action" + surfaceChat surfaceKind = "chat" +) + +type requestSpec struct { + provider string + entry appconfig.SurfaceConfig + fallbackModel string + options []llm.RequestOption + index int +} + +func (r requestSpec) effectiveModel(defaultModel string) string <span class="cov7" title="63">{ + if m := strings.TrimSpace(r.entry.Model); m != "" </span><span class="cov0" title="0">{ + return m + }</span> + <span class="cov7" title="63">if f := strings.TrimSpace(r.fallbackModel); f != "" </span><span class="cov1" title="1">{ + return f + }</span> + <span class="cov7" title="62">return strings.TrimSpace(defaultModel)</span> +} + +func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec <span class="cov7" title="44">{ + cfg := s.currentConfig() + entries := surfaceConfigsFor(cfg, surface) + if len(entries) == 0 </span><span class="cov7" title="43">{ + entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} + }</span> + <span class="cov7" title="44">maxTokens := s.maxTokens() + specs := make([]requestSpec, 0, len(entries)) + for idx, raw := range entries </span><span class="cov7" title="45">{ + entry := appconfig.SurfaceConfig{ + Provider: strings.TrimSpace(raw.Provider), + Model: strings.TrimSpace(raw.Model), + Temperature: raw.Temperature, + } + provider := entry.Provider + if provider == "" </span><span class="cov7" title="43">{ + provider = cfg.Provider + }</span> + <span class="cov7" title="45">provider = canonicalProvider(provider) + fallbackModel := entry.Model + if fallbackModel == "" </span><span class="cov7" title="43">{ + fallbackModel = strings.TrimSpace(resolveDefaultModel(cfg, provider)) + }</span> + <span class="cov7" title="45">opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} + if entry.Model != "" </span><span class="cov2" title="2">{ + opts = append(opts, llm.WithModel(entry.Model)) + }</span> + <span class="cov7" title="45">if temp, ok := chooseSurfaceTemperature(surface, cfg, entry, provider, fallbackModel); ok </span><span class="cov2" title="2">{ + opts = append(opts, llm.WithTemperature(temp)) + }</span> + <span class="cov7" title="45">specs = append(specs, requestSpec{ + provider: provider, + entry: entry, + fallbackModel: fallbackModel, + options: opts, + index: idx, + })</span> + } + <span class="cov7" title="44">return specs</span> +} + +func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ + specs := s.buildRequestSpecs(surface) + if len(specs) == 0 </span><span class="cov0" title="0">{ + cfg := s.currentConfig() + provider := canonicalProvider(cfg.Provider) + fallback := strings.TrimSpace(resolveDefaultModel(cfg, provider)) + return requestSpec{provider: provider, fallbackModel: fallback, options: []llm.RequestOption{llm.WithMaxTokens(s.maxTokens())}} + }</span> + <span class="cov6" title="32">return specs[0]</span> +} + +// buildRequestSpec is retained for consumers expecting a single-entry helper. +func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec <span class="cov6" title="32">{ + return s.primaryRequestSpec(surface) +}</span> + +func canonicalProvider(name string) string <span class="cov9" title="217">{ + p := strings.ToLower(strings.TrimSpace(name)) + if p == "" </span><span class="cov9" title="163">{ + return "openai" + }</span> + <span class="cov7" title="54">return p</span> +} + +func resolveDefaultModel(cfg appconfig.App, provider string) string <span class="cov7" title="43">{ + switch provider </span>{ + case "ollama":<span class="cov0" title="0"> + return strings.TrimSpace(cfg.OllamaModel)</span> + case "anthropic":<span class="cov0" title="0"> + return strings.TrimSpace(cfg.AnthropicModel)</span> + case "openrouter":<span class="cov0" title="0"> + return strings.TrimSpace(cfg.OpenRouterModel)</span> + default:<span class="cov7" title="43"> + return strings.TrimSpace(cfg.OpenAIModel)</span> + } +} + +func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig <span class="cov7" title="44">{ + switch surface </span>{ + case surfaceCompletion:<span class="cov5" title="16"> + return cfg.CompletionConfigs</span> + case surfaceCodeAction:<span class="cov5" title="20"> + return cfg.CodeActionConfigs</span> + case surfaceChat:<span class="cov4" title="8"> + return cfg.ChatConfigs</span> + default:<span class="cov0" title="0"> + return nil</span> + } +} + +func chooseSurfaceTemperature(surface surfaceKind, cfg appconfig.App, entry appconfig.SurfaceConfig, provider string, fallbackModel string) (float64, bool) <span class="cov7" title="51">{ + if entry.Temperature != nil </span><span class="cov1" title="1">{ + return *entry.Temperature, true + }</span> + <span class="cov7" title="50">if cfg.CodingTemperature != nil </span><span class="cov1" title="1">{ + temp := *cfg.CodingTemperature + effectiveModel := strings.TrimSpace(entry.Model) + if effectiveModel == "" </span><span class="cov1" title="1">{ + effectiveModel = strings.TrimSpace(fallbackModel) + }</span> + <span class="cov1" title="1">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") && temp == 0.2 </span><span class="cov1" title="1">{ + temp = 1.0 + }</span> + <span class="cov1" title="1">return temp, true</span> + } + <span class="cov7" title="49">effectiveModel := strings.TrimSpace(entry.Model) + if effectiveModel == "" </span><span class="cov7" title="48">{ + effectiveModel = strings.TrimSpace(fallbackModel) + }</span> + <span class="cov7" title="49">if provider == "openai" && strings.HasPrefix(strings.ToLower(effectiveModel), "gpt-5") </span><span class="cov0" title="0">{ + return 1.0, true + }</span> + <span class="cov7" title="49">return 0, false</span> +} + +// small helpers for LLM traffic stats +func (s *Server) incSentCounters(n int) <span class="cov7" title="42">{ + s.mu.Lock() + s.llmReqTotal++ + s.llmSentBytesTotal += int64(n) + s.mu.Unlock() +}</span> + +func (s *Server) incRecvCounters(n int) <span class="cov7" title="41">{ + s.mu.Lock() + s.llmRespTotal++ + s.llmRespBytesTotal += int64(n) + s.mu.Unlock() +}</span> + +func (s *Server) logLLMStats(model string) <span class="cov7" title="42">{ + s.mu.RLock() + avgSent := int64(0) + if s.llmReqTotal > 0 </span><span class="cov7" title="42">{ + avgSent = s.llmSentBytesTotal / s.llmReqTotal + }</span> + <span class="cov7" title="42">avgRecv := int64(0) + if s.llmRespTotal > 0 </span><span class="cov7" title="41">{ + avgRecv = s.llmRespBytesTotal / s.llmRespTotal + }</span> + <span class="cov7" title="42">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + s.mu.RUnlock() + mins := time.Since(s.startTime).Minutes() + if mins <= 0 </span><span class="cov0" title="0">{ + mins = 0.001 + }</span> + <span class="cov7" title="42">rpmLocal := float64(reqs) / mins + sentPerMin := float64(sentTot) / mins + recvPerMin := float64(recvTot) / mins + // Log local process counters + logging.Logf("lsp ", "llm stats (local) 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, rpmLocal, sentPerMin, recvPerMin) + // Global snapshot for tmux status + snap, err := stats.TakeSnapshot() + if err == nil </span><span class="cov7" title="42">{ + if client := s.currentLLMClient(); client != nil </span><span class="cov7" title="40">{ + provider := client.Name() + modelName := strings.TrimSpace(model) + if modelName == "" </span><span class="cov0" title="0">{ + modelName = client.DefaultModel() + }</span> + // Per-scope rpm estimated from window + <span class="cov7" title="40">scopeReqs := int64(0) + if pe, ok := snap.Providers[provider]; ok </span><span class="cov7" title="40">{ + if mc, ok2 := pe.Models[modelName]; ok2 </span><span class="cov6" title="37">{ + scopeReqs = mc.Reqs + }</span> + } + <span class="cov7" title="40">minsWin := snap.Window.Minutes() + if minsWin <= 0 </span><span class="cov0" title="0">{ + minsWin = 0.001 + }</span> + <span class="cov7" title="40">scopeRPM := float64(scopeReqs) / minsWin + status := tmx.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, modelName, scopeRPM, scopeReqs, snap.Window) + _ = tmx.SetStatus(status)</span> + } + } +} + +// Completion prompt builders and filters +func inParamList(current string, cursor int) bool <span class="cov5" title="14">{ + if !strings.Contains(current, "func ") </span><span class="cov4" title="8">{ + return false + }</span> + <span class="cov3" title="6">open := strings.Index(current, "(") + close := strings.Index(current, ")") + return open >= 0 && cursor > open && (close == -1 || cursor <= close)</span> +} + +// renderTemplate performs simple {{var}} replacement in a template string. +func renderTemplate(t string, vars map[string]string) string <span class="cov7" title="45">{ return textutil.RenderTemplate(t, vars) }</span> + +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov5" title="19">{ + if inParams </span><span class="cov2" title="3">{ + open := strings.Index(current, "(") + close := strings.Index(current, ")") + if open >= 0 </span><span class="cov2" title="3">{ + left := open + 1 + right := len(current) + if close >= 0 && close >= left </span><span class="cov2" title="3">{ + right = close + }</span> + <span class="cov2" title="3">if p.Position.Character < right </span><span class="cov2" title="2">{ + right = p.Position.Character + }</span> + <span class="cov2" title="3">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) </span><span class="cov2" title="3">{ + filter = strings.TrimLeft(current[left:right], " \t") + }</span> + <span class="cov2" title="3">return te, filter</span> + } + } + <span class="cov5" title="16">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</span> +} + +func computeWordStart(current string, at int) int <span class="cov6" title="27">{ + if at > len(current) </span><span class="cov0" title="0">{ + at = len(current) + }</span> + <span class="cov6" title="27">for at > 0 </span><span class="cov7" title="54">{ + ch := current[at-1] + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov6" title="34">{ + at-- + continue</span> + } + <span class="cov5" title="20">break</span> + } + <span class="cov6" title="27">return at</span> +} + +func isIdentChar(ch byte) bool <span class="cov9" title="175">{ + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' +}</span> + +// chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. +func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) <span class="cov6" title="28">{ + // Count bytes sent + sent := 0 + for _, m := range msgs </span><span class="cov7" title="59">{ + sent += len(m.Content) + }</span> + <span class="cov6" title="28">s.incSentCounters(sent) + // Debounce/throttle if configured (reuse completion gates) + s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ + return "", context.Canceled + }</span> + // Perform request + <span class="cov6" title="28">client := s.clientFor(spec) + if client == nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("llm client unavailable") + }</span> + <span class="cov6" title="28">modelUsed := spec.effectiveModel(client.DefaultModel()) + txt, err := client.Chat(ctx, msgs, spec.options...) + if err != nil </span><span class="cov1" title="1">{ + s.logLLMStats(modelUsed) + return "", err + }</span> + <span class="cov6" title="27">s.incRecvCounters(len(txt)) + // Update global stats cache + _ = stats.Update(ctx, client.Name(), modelUsed, sent, len(txt)) + s.logLLMStats(modelUsed) + return txt, nil</span> +} + +// Inline prompt utilities + +func lineHasInlinePrompt(line string, openStr string, open, close byte) bool <span class="cov7" title="45">{ + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) + }</span> + <span class="cov7" title="45">if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok </span><span class="cov3" title="5">{ + return true + }</span> + <span class="cov7" title="40">return hasDoubleOpenTrigger(line, openStr, open, close)</span> +} + +func doubleOpenSequences(openStr string, open, close byte) []string <span class="cov10" title="226">{ + seen := make(map[string]struct{}, 2) + var seqs []string + if openStr != "" && close != 0 </span><span class="cov10" title="226">{ + seq := openStr + string(close) + if _, ok := seen[seq]; !ok </span><span class="cov10" title="226">{ + seen[seq] = struct{}{} + seqs = append(seqs, seq) + }</span> + } + <span class="cov10" title="226">if openStr != "" && open != 0 </span><span class="cov10" title="226">{ + seq := string(open) + openStr + if len(seq) > len(openStr) </span><span class="cov10" title="226">{ + if _, ok := seen[seq]; !ok </span><span class="cov9" title="223">{ + seen[seq] = struct{}{} + seqs = append(seqs, seq) + }</span> + } + } + <span class="cov10" title="226">return seqs</span> +} + +func leadingIndent(line string) string <span class="cov3" title="5">{ + i := 0 + for i < len(line) </span><span class="cov5" title="15">{ + if line[i] == ' ' || line[i] == '\t' </span><span class="cov4" title="10">{ + i++ + continue</span> + } + <span class="cov3" title="5">break</span> + } + <span class="cov3" title="5">if i == 0 </span><span class="cov1" title="1">{ + return "" + }</span> + <span class="cov3" title="4">return line[:i]</span> +} + +func applyIndent(indent, suggestion string) string <span class="cov3" title="4">{ + if indent == "" || suggestion == "" </span><span class="cov0" title="0">{ + return suggestion + }</span> + <span class="cov3" title="4">lines := splitLines(suggestion) + for i, ln := range lines </span><span class="cov4" title="10">{ + if strings.TrimSpace(ln) == "" </span><span class="cov1" title="1">{ + continue</span> + } + <span class="cov4" title="9">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov4" title="9">lines[i] = indent + ln</span> + } + <span class="cov3" title="4">return strings.Join(lines, "\n")</span> +} + +// --- 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, openStr string, open, close byte) (string, int, int, bool) <span class="cov8" title="76">{ + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) + }</span> + <span class="cov8" title="76">if openStr == "" </span><span class="cov0" title="0">{ + return "", 0, 0, false + }</span> + <span class="cov8" title="76">openChar := open + if openChar == 0 </span><span class="cov0" title="0">{ + openChar = openStr[0] + }</span> + <span class="cov8" title="76">doubleSeqs := doubleOpenSequences(openStr, openChar, close) + pos := 0 + for pos < len(line) </span><span class="cov8" title="90">{ + j := strings.IndexByte(line[pos:], openChar) + if j < 0 </span><span class="cov7" title="40">{ + return "", 0, 0, false + }</span> + <span class="cov7" title="50">j += pos + if !strings.HasPrefix(line[j:], openStr) </span><span class="cov6" title="26">{ + pos = j + 1 + continue</span> + } + <span class="cov6" title="24">contentStart := j + len(openStr) + if contentStart >= len(line) </span><span class="cov2" title="2">{ + return "", 0, 0, false + }</span> + <span class="cov6" title="22">doubleHit := false + for _, seq := range doubleSeqs </span><span class="cov7" title="44">{ + if strings.HasPrefix(line[j:], seq) </span><span class="cov0" title="0">{ + doubleHit = true + contentStart += len(seq) - len(openStr) + if contentStart >= len(line) </span><span class="cov0" title="0">{ + return "", 0, 0, false + }</span> + <span class="cov0" title="0">break</span> + } + } + <span class="cov6" title="22">next := line[contentStart] + if next == ' ' </span><span class="cov3" title="5">{ + pos = contentStart + 1 + continue</span> + } + <span class="cov5" title="17">if !doubleHit && next == close </span><span class="cov0" title="0">{ + pos = contentStart + 1 + continue</span> + } + <span class="cov5" title="17">k := strings.IndexByte(line[contentStart:], close) + if k < 0 </span><span class="cov0" title="0">{ + return "", 0, 0, false + }</span> + <span class="cov5" title="17">closeIdx := contentStart + k + if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="16">inner := strings.TrimSpace(line[contentStart:closeIdx]) + if inner == "" </span><span class="cov0" title="0">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="16">end := closeIdx + 1 + return inner, j, end, true</span> + } + <span class="cov5" title="18">return "", 0, 0, false</span> +} + +// 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, openStr string, open, close byte) bool <span class="cov6" title="24">{ + t := strings.TrimSpace(line) + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) + }</span> + <span class="cov6" title="24">if openStr == "" </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov6" title="24">for _, seq := range doubleOpenSequences(openStr, open, close) </span><span class="cov7" title="48">{ + if strings.HasPrefix(t, seq) </span><span class="cov2" title="2">{ + rest := strings.TrimSpace(t[len(seq):]) + if rest == "" || rest == string(close) </span><span class="cov1" title="1">{ + return true + }</span> + } + } + <span class="cov6" title="23">return false</span> +} + +// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="21">{ + s2 := strings.TrimLeft(suggestion, " \t") + // Prefer := if present at end of prefix + if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) </span><span class="cov3" title="4">{ + tail := prefixBeforeCursor[idx+2:] + if strings.TrimSpace(tail) == "" </span><span class="cov3" title="4">{ + start := idx - 1 + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov5" title="20">{ + start-- + }</span> + <span class="cov3" title="4">start++ + seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t") + if strings.HasPrefix(s2, seg) </span><span class="cov3" title="4">{ + return strings.TrimLeft(s2[len(seg):], " \t") + }</span> + } + } + // Fallback to plain '=' if present + <span class="cov5" title="17">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ + if idx <= 0 || prefixBeforeCursor[idx-1] != ':' </span><span class="cov2" title="2">{ // not := + tail := prefixBeforeCursor[idx+1:] + if strings.TrimSpace(tail) == "" </span><span class="cov2" title="2">{ + start := idx - 1 + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov3" title="4">{ + start-- + }</span> + <span class="cov2" title="2">start++ + seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t") + if strings.HasPrefix(s2, seg) </span><span class="cov2" title="2">{ + return strings.TrimLeft(s2[len(seg):], " \t") + }</span> + } + } + } + <span class="cov5" title="15">return suggestion</span> +} + +// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="21">{ + if suggestion == "" </span><span class="cov0" title="0">{ + return suggestion + }</span> + <span class="cov6" title="21">s := strings.TrimLeft(suggestion, " \t") + p := strings.TrimRight(prefixBeforeCursor, " \t") + if p != "" && strings.HasPrefix(s, p) </span><span class="cov3" title="5">{ + return strings.TrimLeft(s[len(p):], " \t") + }</span> + <span class="cov5" title="16">for k := len(p) - 1; k > 0; k-- </span><span class="cov9" title="149">{ + if !isIdentBoundary(p[k-1]) </span><span class="cov8" title="116">{ + continue</span> + } + <span class="cov6" title="33">suf := strings.TrimLeft(p[k:], " \t") + if suf == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov6" title="33">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ + return strings.TrimLeft(s[len(suf):], " \t") + }</span> + } + <span class="cov5" title="16">return suggestion</span> +} + +func isIdentBoundary(ch byte) bool <span class="cov9" title="149">{ + return !isIdentChar(ch) +}</span> + +// stripCodeFences removes surrounding Markdown code fences from a model response. +func stripCodeFences(s string) string <span class="cov7" title="48">{ return textutil.StripCodeFences(s) }</span> + +// stripInlineCodeSpan returns the contents of the first inline backtick code span if present. +func stripInlineCodeSpan(s string) string <span class="cov4" title="11">{ + t := strings.TrimSpace(s) + if t == "" </span><span class="cov0" title="0">{ + return t + }</span> + <span class="cov4" title="11">i := strings.IndexByte(t, '`') + if i < 0 </span><span class="cov2" title="2">{ + return t + }</span> + <span class="cov4" title="9">jrel := strings.IndexByte(t[i+1:], '`') + if jrel < 0 </span><span class="cov2" title="2">{ + return t + }</span> + <span class="cov4" title="7">j := i + 1 + jrel + return t[i+1 : j]</span> +} + +// labelForCompletion picks a short, readable label for the completion list. +func labelForCompletion(cleaned, filter string) string <span class="cov6" title="22">{ + label := trimLen(firstLine(cleaned)) + if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov3" title="5">{ + return filter + }</span> + <span class="cov5" title="17">return label</span> +} + +// extractRangeText returns the exact text within the given document range. +func extractRangeText(d *document, r Range) string <span class="cov3" title="6">{ + if r.Start.Line == r.End.Line </span><span class="cov3" title="5">{ + line := d.lines[r.Start.Line] + if r.Start.Character < 0 </span><span class="cov0" title="0">{ + r.Start.Character = 0 + }</span> + <span class="cov3" title="5">if r.End.Character > len(line) </span><span class="cov0" title="0">{ + r.End.Character = len(line) + }</span> + <span class="cov3" title="5">if r.Start.Character > r.End.Character </span><span class="cov1" title="1">{ + return "" + }</span> + <span class="cov3" title="4">return line[r.Start.Character:r.End.Character]</span> + } + <span class="cov1" title="1">var b strings.Builder + // first line + first := d.lines[r.Start.Line] + if r.Start.Character < 0 </span><span class="cov0" title="0">{ + r.Start.Character = 0 + }</span> + <span class="cov1" title="1">if r.Start.Character > len(first) </span><span class="cov0" title="0">{ + r.Start.Character = len(first) + }</span> + <span class="cov1" title="1">b.WriteString(first[r.Start.Character:]) + b.WriteString("\n") + // middle lines + for i := r.Start.Line + 1; i < r.End.Line; i++ </span><span class="cov1" title="1">{ + b.WriteString(d.lines[i]) + if i+1 <= r.End.Line </span><span class="cov1" title="1">{ + b.WriteString("\n") + }</span> + } + // last line + <span class="cov1" title="1">last := d.lines[r.End.Line] + if r.End.Character < 0 </span><span class="cov0" title="0">{ + r.End.Character = 0 + }</span> + <span class="cov1" title="1">if r.End.Character > len(last) </span><span class="cov0" title="0">{ + r.End.Character = len(last) + }</span> + <span class="cov1" title="1">b.WriteString(last[:r.End.Character]) + return b.String()</span> +} + +// collectPromptRemovalEdits returns edits to remove all inline prompt markers. +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="15">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov4" title="11">{ + return nil + }</span> + <span class="cov3" title="4">var edits []TextEdit + openStr, _, openChar, closeChar := s.inlineMarkers() + for i, line := range d.lines </span><span class="cov5" title="13">{ + edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...) + }</span> + <span class="cov3" title="4">return edits</span> +} + +func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov5" title="17">{ + if hasDoubleOpenTrigger(line, openStr, open, close) </span><span class="cov3" title="5">{ + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + }</span> + <span class="cov5" title="12">return collectSemicolonMarkers(line, lineNum, openStr, open, close)</span> +} + +func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool <span class="cov8" title="87">{ + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) + }</span> + <span class="cov8" title="87">if openStr == "" </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov8" title="87">seqs := doubleOpenSequences(openStr, open, close) + if len(seqs) == 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov8" title="87">pos := 0 + for pos < len(line) </span><span class="cov8" title="86">{ + found := -1 + var seq string + for _, cand := range seqs </span><span class="cov9" title="171">{ + if cand == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov9" title="171">if idx := strings.Index(line[pos:], cand); idx >= 0 </span><span class="cov6" title="25">{ + abs := pos + idx + if found < 0 || abs < found </span><span class="cov6" title="25">{ + found = abs + seq = cand + }</span> + } + } + <span class="cov8" title="86">if found < 0 </span><span class="cov7" title="62">{ + return false + }</span> + <span class="cov6" title="24">contentStart := found + len(seq) + if contentStart >= len(line) </span><span class="cov4" title="7">{ + return false + }</span> + <span class="cov5" title="17">first := line[contentStart] + if first == ' ' || first == close || first == open </span><span class="cov3" title="5">{ + pos = contentStart + 1 + continue</span> + } + <span class="cov5" title="12">if contentStart+1 >= len(line) </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="12">k := strings.IndexByte(line[contentStart+1:], close) + if k < 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="12">closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov4" title="11">return true</span> + } + <span class="cov4" title="7">return false</span> +} + +func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit <span class="cov5" title="14">{ + if openStr == "" </span><span class="cov0" title="0">{ + openStr = string(open) + }</span> + <span class="cov5" title="14">if openStr == "" </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov5" title="14">var edits []TextEdit + start := 0 + doubleSeqs := doubleOpenSequences(openStr, open, close) + for start < len(line) </span><span class="cov5" title="18">{ + j := strings.Index(line[start:], openStr) + if j < 0 </span><span class="cov5" title="12">{ + break</span> + } + <span class="cov3" title="6">j += start + contentStart := j + len(openStr) + if contentStart >= len(line) </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov3" title="6">next := line[contentStart] + if next == ' ' </span><span class="cov0" title="0">{ + start = j + 1 + continue</span> + } + <span class="cov3" title="6">skipDouble := false + for _, seq := range doubleSeqs </span><span class="cov4" title="11">{ + if strings.HasPrefix(line[j:], seq) </span><span class="cov0" title="0">{ + skipDouble = true + break</span> + } + } + <span class="cov3" title="6">if skipDouble </span><span class="cov0" title="0">{ + start = j + 1 + continue</span> + } + <span class="cov3" title="6">k := strings.IndexByte(line[contentStart:], close) + if k < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov3" title="6">closeIdx := contentStart + k + if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ + start = closeIdx + 1 + continue</span> + } + <span class="cov3" title="6">if closeIdx == contentStart </span><span class="cov0" title="0">{ + start = closeIdx + 1 + continue</span> + } + <span class="cov3" title="6">endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ + endChar++ + }</span> + <span class="cov3" title="6">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + start = endChar</span> + } + <span class="cov5" title="14">return edits</span> +} +</pre> + + <pre class="file" id="file36" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. +package lsp + +import ( + "bufio" + "encoding/json" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/ignore" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/runtimeconfig" +) + +// Server implements a minimal LSP over stdio. +type Server struct { + in *bufio.Reader + out io.Writer + outMu sync.Mutex + logger *log.Logger + exited bool + mu sync.RWMutex + docs map[string]*document + logContext bool + configStore *runtimeconfig.Store + cfg appconfig.App + llmClient llm.Client + llmProvider string + altClients map[string]llm.Client + lastInput time.Time + // 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 + pendingCompletions map[string][]CompletionItem + configLoadOpts appconfig.LoadOptions + // Outgoing JSON-RPC id counter for server-initiated requests + nextID int64 + lastLLMCall time.Time + + completionsDisabled bool + + // Gitignore-aware file checker (nil when disabled) + ignoreChecker *ignore.Checker + + // Dispatch table for JSON-RPC methods → handler functions + handlers map[string]func(Request) +} + +// ServerOptions collects configuration for NewServer to avoid long parameter lists. +type ServerOptions struct { + LogContext bool + ConfigStore *runtimeconfig.Store + Config *appconfig.App + MaxTokens int + ContextMode string + WindowLines int + MaxContextTokens int + ConfigLoadOptions appconfig.LoadOptions + + Client llm.Client + TriggerCharacters []string + CodingTemperature *float64 + ManualInvokeMinPrefix int + CompletionDebounceMs int + CompletionThrottleMs int + CompletionWaitAll *bool + + // 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 + PromptSimplifySystem string + PromptSimplifyUser string + + // Custom actions + CustomActions []CustomAction + + // Gitignore-aware file checker (optional) + IgnoreChecker *ignore.Checker +} + +// CustomAction mirrors user-defined code actions passed from config. +type CustomAction struct { + ID string + Title string + Kind string + Scope string // "selection" | "diagnostics" + Instruction string // if set, use rewrite templates + System string // optional when User is set + User string // if set, use this user template +} + +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov4" title="8">{ + s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext, configStore: opts.ConfigStore} + s.startTime = time.Now() + s.compCache = make(map[string]string) + s.pendingCompletions = make(map[string][]CompletionItem) + s.applyOptions(opts) + // Initialize dispatch table + s.handlers = map[string]func(Request){ + "initialize": s.handleInitialize, + "initialized": func(_ Request) </span><span class="cov0" title="0">{ s.handleInitialized() }</span>, + "shutdown": s.handleShutdown, + "exit": func(_ Request) <span class="cov0" title="0">{ s.handleExit() }</span>, + "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, + } + <span class="cov4" title="8">return s</span> +} + +func (s *Server) applyOptions(opts ServerOptions) <span class="cov4" title="9">{ + s.mu.Lock() + defer s.mu.Unlock() + s.logContext = opts.LogContext + s.configLoadOpts = opts.ConfigLoadOptions + if opts.ConfigStore != nil </span><span class="cov1" title="1">{ + s.configStore = opts.ConfigStore + }</span> + <span class="cov4" title="9">if opts.Config != nil </span><span class="cov2" title="2">{ + s.cfg = *opts.Config + }</span> else<span class="cov3" title="7"> if opts.ConfigStore != nil </span><span class="cov0" title="0">{ + s.cfg = opts.ConfigStore.Snapshot() + }</span> else<span class="cov3" title="7"> { + s.cfg = appconfig.App{} + // populate from legacy ServerOptions fields + s.cfg.MaxTokens = opts.MaxTokens + s.cfg.ContextMode = opts.ContextMode + s.cfg.ContextWindowLines = opts.WindowLines + s.cfg.MaxContextTokens = opts.MaxContextTokens + s.cfg.TriggerCharacters = append([]string{}, opts.TriggerCharacters...) + s.cfg.CodingTemperature = opts.CodingTemperature + s.cfg.ManualInvokeMinPrefix = opts.ManualInvokeMinPrefix + s.cfg.CompletionDebounceMs = opts.CompletionDebounceMs + s.cfg.CompletionThrottleMs = opts.CompletionThrottleMs + s.cfg.CompletionWaitAll = opts.CompletionWaitAll + s.cfg.InlineOpen = opts.InlineOpen + s.cfg.InlineClose = opts.InlineClose + s.cfg.ChatSuffix = opts.ChatSuffix + s.cfg.ChatPrefixes = append([]string{}, opts.ChatPrefixes...) + s.cfg.PromptCompletionSystemGeneral = opts.PromptCompSysGeneral + s.cfg.PromptCompletionSystemParams = opts.PromptCompSysParams + s.cfg.PromptCompletionSystemInline = opts.PromptCompSysInline + s.cfg.PromptCompletionUserGeneral = opts.PromptCompUserGeneral + s.cfg.PromptCompletionUserParams = opts.PromptCompUserParams + s.cfg.PromptCompletionExtraHeader = opts.PromptCompExtraHeader + s.cfg.PromptNativeCompletion = opts.PromptNativeCompletion + s.cfg.PromptChatSystem = opts.PromptChatSystem + s.cfg.PromptCodeActionRewriteSystem = opts.PromptRewriteSystem + s.cfg.PromptCodeActionDiagnosticsSystem = opts.PromptDiagnosticsSystem + s.cfg.PromptCodeActionDocumentSystem = opts.PromptDocumentSystem + s.cfg.PromptCodeActionRewriteUser = opts.PromptRewriteUser + s.cfg.PromptCodeActionDiagnosticsUser = opts.PromptDiagnosticsUser + s.cfg.PromptCodeActionDocumentUser = opts.PromptDocumentUser + s.cfg.PromptCodeActionGoTestSystem = opts.PromptGoTestSystem + s.cfg.PromptCodeActionGoTestUser = opts.PromptGoTestUser + s.cfg.PromptCodeActionSimplifySystem = opts.PromptSimplifySystem + s.cfg.PromptCodeActionSimplifyUser = opts.PromptSimplifyUser + s.cfg.CustomActions = make([]appconfig.CustomAction, len(opts.CustomActions)) + for i, ca := range opts.CustomActions </span><span class="cov0" title="0">{ + s.cfg.CustomActions[i] = appconfig.CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + } + }</span> + } + <span class="cov4" title="9">s.llmClient = opts.Client + if opts.Client != nil </span><span class="cov2" title="2">{ + s.llmProvider = canonicalProvider(opts.Client.Name()) + }</span> else<span class="cov3" title="7"> { + s.llmProvider = canonicalProvider(s.cfg.Provider) + }</span> + <span class="cov4" title="9">s.altClients = make(map[string]llm.Client) + if opts.IgnoreChecker != nil </span><span class="cov1" title="1">{ + s.ignoreChecker = opts.IgnoreChecker + }</span> +} + +// ApplyOptions updates the server's configuration at runtime. +func (s *Server) ApplyOptions(opts ServerOptions) <span class="cov1" title="1">{ + s.applyOptions(opts) +}</span> + +func (s *Server) currentLLMClient() llm.Client <span class="cov7" title="83">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.llmClient +}</span> + +func newClientForProvider(cfg appconfig.App, provider string) (llm.Client, error) <span class="cov3" title="5">{ + llmCfg := llm.Config{ + Provider: provider, + RequestTimeout: cfg.RequestTimeout, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OpenRouterBaseURL: cfg.OpenRouterBaseURL, + OpenRouterModel: cfg.OpenRouterModel, + OpenRouterTemperature: cfg.OpenRouterTemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + AnthropicBaseURL: cfg.AnthropicBaseURL, + AnthropicModel: cfg.AnthropicModel, + AnthropicTemperature: cfg.AnthropicTemperature, + } + oaKey := strings.TrimSpace(os.Getenv("HEXAI_OPENAI_API_KEY")) + if oaKey == "" </span><span class="cov3" title="5">{ + oaKey = strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) + }</span> + <span class="cov3" title="5">orKey := strings.TrimSpace(os.Getenv("HEXAI_OPENROUTER_API_KEY")) + if orKey == "" </span><span class="cov3" title="5">{ + orKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + }</span> + <span class="cov3" title="5">anKey := strings.TrimSpace(os.Getenv("HEXAI_ANTHROPIC_API_KEY")) + if anKey == "" </span><span class="cov3" title="5">{ + anKey = strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) + }</span> + <span class="cov3" title="5">return llm.NewFromConfig(llmCfg, oaKey, orKey, anKey)</span> +} + +func (s *Server) clientFor(spec requestSpec) llm.Client <span class="cov6" title="47">{ + provider := canonicalProvider(spec.provider) + s.mu.RLock() + baseProvider := s.llmProvider + baseClient := s.llmClient + if baseClient != nil && strings.TrimSpace(baseProvider) == "" </span><span class="cov2" title="3">{ + baseProvider = canonicalProvider(baseClient.Name()) + }</span> + <span class="cov6" title="47">if provider == "" </span><span class="cov0" title="0">{ + provider = baseProvider + }</span> + <span class="cov6" title="47">if provider == baseProvider && baseClient != nil </span><span class="cov6" title="42">{ + s.mu.RUnlock() + return baseClient + }</span> + <span class="cov3" title="5">if c, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ + s.mu.RUnlock() + return c + }</span> + <span class="cov3" title="5">cfg := s.cfg + store := s.configStore + s.mu.RUnlock() + if store != nil </span><span class="cov0" title="0">{ + cfg = store.Snapshot() + }</span> + <span class="cov3" title="5">cfg.Provider = provider + modelOverride := strings.TrimSpace(spec.entry.Model) + switch provider </span>{ + case "openai":<span class="cov3" title="5"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.OpenAIModel = modelOverride + }</span> else<span class="cov3" title="5"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.OpenAIModel = spec.fallbackModel + }</span> + case "openrouter":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.OpenRouterModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.OpenRouterModel = spec.fallbackModel + }</span> + case "ollama":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.OllamaModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.OllamaModel = spec.fallbackModel + }</span> + case "anthropic":<span class="cov0" title="0"> + if modelOverride != "" </span><span class="cov0" title="0">{ + cfg.AnthropicModel = modelOverride + }</span> else<span class="cov0" title="0"> if spec.fallbackModel != "" </span><span class="cov0" title="0">{ + cfg.AnthropicModel = spec.fallbackModel + }</span> + } + <span class="cov3" title="5">client, err := newClientForProvider(cfg, provider) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "failed to build client for provider=%s: %v", provider, err) + if baseClient != nil </span><span class="cov0" title="0">{ + return baseClient + }</span> + <span class="cov0" title="0">return nil</span> + } + <span class="cov3" title="5">s.mu.Lock() + defer s.mu.Unlock() + if provider == s.llmProvider </span><span class="cov1" title="1">{ + if s.llmClient == nil </span><span class="cov1" title="1">{ + s.llmClient = client + s.llmProvider = provider + }</span> + <span class="cov1" title="1">return s.llmClient</span> + } + <span class="cov3" title="4">if existing, ok := s.altClients[provider]; ok </span><span class="cov0" title="0">{ + return existing + }</span> + <span class="cov3" title="4">if s.altClients == nil </span><span class="cov3" title="4">{ + s.altClients = make(map[string]llm.Client) + }</span> + <span class="cov3" title="4">s.altClients[provider] = client + return client</span> +} + +func (s *Server) currentConfig() appconfig.App <span class="cov10" title="445">{ + if s.configStore != nil </span><span class="cov3" title="5">{ + return s.configStore.Snapshot() + }</span> + <span class="cov9" title="440">s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg</span> +} + +func (s *Server) storePendingCompletion(key string, items []CompletionItem) <span class="cov1" title="1">{ + if len(items) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov1" title="1">cpy := make([]CompletionItem, len(items)) + copy(cpy, items) + s.mu.Lock() + if s.pendingCompletions == nil </span><span class="cov1" title="1">{ + s.pendingCompletions = make(map[string][]CompletionItem) + }</span> + <span class="cov1" title="1">s.pendingCompletions[key] = cpy + s.mu.Unlock()</span> +} + +func (s *Server) setCompletionsDisabled(disabled bool) bool <span class="cov3" title="6">{ + s.mu.Lock() + prev := s.completionsDisabled + s.completionsDisabled = disabled + s.mu.Unlock() + return prev +}</span> + +func (s *Server) completionDisabled() bool <span class="cov3" title="6">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.completionsDisabled +}</span> + +func (s *Server) takePendingCompletion(key string) []CompletionItem <span class="cov4" title="12">{ + s.mu.Lock() + defer s.mu.Unlock() + if len(s.pendingCompletions) == 0 </span><span class="cov4" title="11">{ + return nil + }</span> + <span class="cov1" title="1">items, ok := s.pendingCompletions[key] + if !ok </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov1" title="1">delete(s.pendingCompletions, key) + cpy := make([]CompletionItem, len(items)) + copy(cpy, items) + return cpy</span> +} + +func (s *Server) maxTokens() int <span class="cov6" title="44">{ + cfg := s.currentConfig() + if cfg.MaxTokens <= 0 </span><span class="cov6" title="36">{ + return 500 + }</span> + <span class="cov4" title="8">return cfg.MaxTokens</span> +} + +func (s *Server) contextMode() string <span class="cov4" title="14">{ + mode := strings.TrimSpace(s.currentConfig().ContextMode) + if mode == "" </span><span class="cov3" title="5">{ + return "file-on-new-func" + }</span> + <span class="cov4" title="9">return mode</span> +} + +func (s *Server) windowLines() int <span class="cov2" title="2">{ + cfg := s.currentConfig() + if cfg.ContextWindowLines <= 0 </span><span class="cov0" title="0">{ + return 120 + }</span> + <span class="cov2" title="2">return cfg.ContextWindowLines</span> +} + +func (s *Server) maxContextTokens() int <span class="cov3" title="6">{ + cfg := s.currentConfig() + if cfg.MaxContextTokens <= 0 </span><span class="cov0" title="0">{ + return 2000 + }</span> + <span class="cov3" title="6">return cfg.MaxContextTokens</span> +} + +func (s *Server) triggerCharacters() []string <span class="cov5" title="27">{ + cfg := s.currentConfig() + if len(cfg.TriggerCharacters) == 0 </span><span class="cov2" title="3">{ + return []string{".", ":", "/", "_", ")", "{"} + }</span> + <span class="cov5" title="24">return append([]string{}, cfg.TriggerCharacters...)</span> +} + +func (s *Server) codingTemperature() *float64 <span class="cov4" title="11">{ + cfg := s.currentConfig() + return cfg.CodingTemperature +}</span> + +func (s *Server) manualInvokeMinPrefix() int <span class="cov3" title="5">{ + return s.currentConfig().ManualInvokeMinPrefix +}</span> + +func (s *Server) completionDebounce() time.Duration <span class="cov6" title="40">{ + cfg := s.currentConfig() + if cfg.CompletionDebounceMs <= 0 </span><span class="cov6" title="38">{ + return 0 + }</span> + <span class="cov2" title="2">return time.Duration(cfg.CompletionDebounceMs) * time.Millisecond</span> +} + +func (s *Server) completionThrottle() time.Duration <span class="cov6" title="40">{ + cfg := s.currentConfig() + if cfg.CompletionThrottleMs <= 0 </span><span class="cov6" title="37">{ + return 0 + }</span> + <span class="cov2" title="3">return time.Duration(cfg.CompletionThrottleMs) * time.Millisecond</span> +} + +func (s *Server) completionWaitAll() bool <span class="cov0" title="0">{ + cfg := s.currentConfig() + if cfg.CompletionWaitAll == nil </span><span class="cov0" title="0">{ + return true // default: wait for all backends + }</span> + <span class="cov0" title="0">return *cfg.CompletionWaitAll</span> +} + +func (s *Server) inlineMarkers() (open string, close string, openChar byte, closeChar byte) <span class="cov7" title="102">{ + cfg := s.currentConfig() + open = strings.TrimSpace(cfg.InlineOpen) + if open == "" </span><span class="cov2" title="2">{ + open = ">!" + }</span> + <span class="cov7" title="102">close = strings.TrimSpace(cfg.InlineClose) + if close == "" </span><span class="cov2" title="2">{ + close = ">" + }</span> + <span class="cov7" title="102">openChar = '>' + if len(open) > 0 </span><span class="cov7" title="102">{ + openChar = open[0] + }</span> + <span class="cov7" title="102">closeChar = '>' + if len(close) > 0 </span><span class="cov7" title="102">{ + closeChar = close[0] + }</span> + <span class="cov7" title="102">return open, close, openChar, closeChar</span> +} + +func (s *Server) chatConfig() (suffix string, prefixes []string, suffixChar byte) <span class="cov6" title="51">{ + cfg := s.currentConfig() + suffix = cfg.ChatSuffix + if suffix != "" </span><span class="cov6" title="49">{ + suffix = strings.TrimSpace(suffix) + if suffix == "" </span><span class="cov0" title="0">{ + suffix = ">" + }</span> + } else<span class="cov2" title="2"> { + suffix = "" + }</span> + <span class="cov6" title="51">if len(cfg.ChatPrefixes) == 0 </span><span class="cov0" title="0">{ + prefixes = []string{"?", "!", ":", ";"} + }</span> else<span class="cov6" title="51"> { + prefixes = append([]string{}, cfg.ChatPrefixes...) + }</span> + <span class="cov6" title="51">suffixChar = '>' + if len(suffix) > 0 </span><span class="cov6" title="49">{ + suffixChar = suffix[0] + }</span> + <span class="cov6" title="51">return suffix, prefixes, suffixChar</span> +} + +func (s *Server) promptSet() appconfig.App <span class="cov2" title="2">{ + return s.currentConfig() +}</span> + +func (s *Server) customActions() []CustomAction <span class="cov3" title="7">{ + cfg := s.currentConfig() + if len(cfg.CustomActions) == 0 </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov3" title="6">customs := make([]CustomAction, 0, len(cfg.CustomActions)) + for _, ca := range cfg.CustomActions </span><span class="cov4" title="10">{ + customs = append(customs, CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + }</span> + <span class="cov3" title="6">return customs</span> +} + +func (s *Server) Run() error <span class="cov1" title="1">{ + for </span><span class="cov1" title="1">{ + body, err := s.readMessage() + if err == io.EOF </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov0" title="0">if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">var req Request + if err := json.Unmarshal(body, &req); err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "invalid JSON: %v", err) + continue</span> + } + <span class="cov0" title="0">if req.Method == "" </span><span class="cov0" title="0">{ + // A response from client; ignore + continue</span> + } + <span class="cov0" title="0">go s.handle(req) + if s.exited </span><span class="cov0" title="0">{ + return nil + }</span> + } +} +</pre> + + <pre class="file" id="file37" style="display: none">// 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) <span class="cov2" title="2">{ + tp := textproto.NewReader(s.in) + var contentLength int + for </span><span class="cov3" title="3">{ + line, err := tp.ReadLine() + if err != nil </span><span class="cov1" title="1">{ + return nil, err + }</span> + <span class="cov2" title="2">if line == "" </span><span class="cov1" title="1">{ // end of headers + break</span> + } + <span class="cov1" title="1">parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov1" title="1">key := strings.TrimSpace(strings.ToLower(parts[0])) + val := strings.TrimSpace(parts[1]) + switch key </span>{ + case "content-length":<span class="cov1" title="1"> + n, err := strconv.Atoi(val) + if err != nil </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("invalid Content-Length: %v", err) + }</span> + <span class="cov1" title="1">contentLength = n</span> + } + } + <span class="cov1" title="1">if contentLength <= 0 </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("missing or invalid Content-Length") + }</span> + <span class="cov1" title="1">buf := make([]byte, contentLength) + if _, err := io.ReadFull(s.in, buf); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov1" title="1">return buf, nil</span> +} + +func (s *Server) writeMessage(v any) <span class="cov10" title="44">{ + s.outMu.Lock() + defer s.outMu.Unlock() + + data, err := json.Marshal(v) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "marshal error: %v", err) + return + }</span> + <span class="cov10" title="44">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + if _, err := io.WriteString(s.out, header); err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "write header error: %v", err) + return + }</span> + <span class="cov10" title="44">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "write body error: %v", err) + return + }</span> +} +</pre> + + <pre class="file" id="file38" style="display: none">package runtimeconfig + +import ( + "fmt" + "log" + "reflect" + "sort" + "strconv" + "strings" + "sync" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// Change captures a single configuration delta. +type Change struct { + Key string + Old string + New string +} + +// Listener receives the previous and new application configuration when updates occur. +type Listener func(old appconfig.App, new appconfig.App) + +// Store holds the active runtime configuration and notifies listeners on updates. +type Store struct { + mu sync.RWMutex + cfg appconfig.App + listeners map[int]Listener + nextID int +} + +// New creates a Store seeded with the provided configuration snapshot. +func New(cfg appconfig.App) *Store <span class="cov4" title="13">{ + return &Store{cfg: cfg, listeners: make(map[int]Listener)} +}</span> + +// Snapshot returns the current configuration snapshot. Callers must treat it as read-only. +func (s *Store) Snapshot() appconfig.App <span class="cov3" title="7">{ + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg +}</span> + +// Subscribe registers a listener that will be invoked on configuration changes. +// The returned function removes the listener. +func (s *Store) Subscribe(listener Listener) func() <span class="cov1" title="2">{ + if listener == nil </span><span class="cov0" title="0">{ + return func() </span>{<span class="cov0" title="0">}</span> + } + <span class="cov1" title="2">s.mu.Lock() + id := s.nextID + s.nextID++ + s.listeners[id] = listener + s.mu.Unlock() + return func() </span><span class="cov0" title="0">{ + s.mu.Lock() + delete(s.listeners, id) + s.mu.Unlock() + }</span> +} + +// Set replaces the current configuration with the provided snapshot and notifies listeners. +// It returns the list of detected changes between the previous and new configuration. +func (s *Store) Set(cfg appconfig.App) []Change <span class="cov3" title="6">{ + s.mu.Lock() + old := s.cfg + s.cfg = cfg + listeners := make([]Listener, 0, len(s.listeners)) + for _, l := range s.listeners </span><span class="cov1" title="1">{ + listeners = append(listeners, l) + }</span> + <span class="cov3" title="6">s.mu.Unlock() + + changes := Diff(old, cfg) + for _, l := range listeners </span><span class="cov1" title="1">{ + l(old, cfg) + }</span> + <span class="cov3" title="6">return changes</span> +} + +// Reload re-reads configuration using the supplied options and applies it when valid. +func (s *Store) Reload(logger *log.Logger, opts appconfig.LoadOptions) ([]Change, error) <span class="cov2" title="4">{ + cfg := appconfig.LoadWithOptions(logger, opts) + if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov2" title="4">changes := s.Set(cfg) + if logger != nil </span><span class="cov2" title="4">{ + logger.Print(FormatSummary("Reloaded config", changes)) + }</span> + <span class="cov2" title="4">return changes, nil</span> +} + +// Diff computes a stable, sorted list of key/value changes between two configuration snapshots. +func Diff(oldCfg, newCfg appconfig.App) []Change <span class="cov3" title="7">{ + before := flattenAppConfig(oldCfg) + after := flattenAppConfig(newCfg) + keys := make(map[string]struct{}, len(before)+len(after)) + for k := range before </span><span class="cov8" title="238">{ + keys[k] = struct{}{} + }</span> + <span class="cov3" title="7">for k := range after </span><span class="cov8" title="238">{ + keys[k] = struct{}{} + }</span> + <span class="cov3" title="7">ordered := make([]string, 0, len(keys)) + for k := range keys </span><span class="cov8" title="238">{ + ordered = append(ordered, k) + }</span> + <span class="cov3" title="7">sort.Strings(ordered) + changes := make([]Change, 0, len(ordered)) + for _, k := range ordered </span><span class="cov8" title="238">{ + if before[k] == after[k] </span><span class="cov8" title="231">{ + continue</span> + } + <span class="cov3" title="7">changes = append(changes, Change{Key: k, Old: before[k], New: after[k]})</span> + } + <span class="cov3" title="7">return changes</span> +} + +func flattenAppConfig(cfg appconfig.App) map[string]string <span class="cov4" title="14">{ + result := make(map[string]string) + val := reflect.ValueOf(cfg) + typ := val.Type() + for i := 0; i < typ.NumField(); i++ </span><span class="cov10" title="882">{ + field := typ.Field(i) + key := strings.TrimSpace(field.Tag.Get("toml")) + if key == "" || key == "-" </span><span class="cov9" title="476">{ + switch field.Name </span>{ + case "StatsWindowMinutes":<span class="cov4" title="14"> + key = "stats_window_minutes"</span> + case "CompletionConfigs":<span class="cov4" title="14"> + key = "completion_configs"</span> + case "CodeActionConfigs":<span class="cov4" title="14"> + key = "code_action_configs"</span> + case "ChatConfigs":<span class="cov4" title="14"> + key = "chat_configs"</span> + case "CLIConfigs":<span class="cov4" title="14"> + key = "cli_configs"</span> + default:<span class="cov8" title="406"> + continue</span> + } + } + <span class="cov9" title="476">if idx := strings.Index(key, ","); idx >= 0 </span><span class="cov0" title="0">{ + key = key[:idx] + }</span> + <span class="cov9" title="476">if key == "" || key == "-" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov9" title="476">result[key] = stringifyValue(val.Field(i))</span> + } + <span class="cov4" title="14">return result</span> +} + +func stringifyValue(v reflect.Value) string <span class="cov9" title="516">{ + if !v.IsValid() </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov9" title="516">switch v.Kind() </span>{ + case reflect.String:<span class="cov7" title="182"> + return v.String()</span> + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:<span class="cov7" title="126"> + return strconv.FormatInt(v.Int(), 10)</span> + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:<span class="cov0" title="0"> + return strconv.FormatUint(v.Uint(), 10)</span> + case reflect.Float32, reflect.Float64:<span class="cov5" title="40"> + return strconv.FormatFloat(v.Float(), 'f', -1, 64)</span> + case reflect.Bool:<span class="cov0" title="0"> + return strconv.FormatBool(v.Bool())</span> + case reflect.Slice:<span class="cov6" title="84"> + if v.IsNil() </span><span class="cov6" title="72">{ + return "" + }</span> + <span class="cov4" title="12">if v.Type().Elem().Kind() == reflect.String </span><span class="cov4" title="10">{ + parts := make([]string, v.Len()) + for i := range parts </span><span class="cov5" title="40">{ + parts[i] = v.Index(i).String() + }</span> + <span class="cov4" title="10">return strings.Join(parts, ",")</span> + } + <span class="cov1" title="2">if v.Type().Elem() == reflect.TypeOf(appconfig.SurfaceConfig{}) </span><span class="cov1" title="2">{ + parts := make([]string, 0, v.Len()) + for i := 0; i < v.Len(); i++ </span><span class="cov1" title="2">{ + entry := v.Index(i).Interface().(appconfig.SurfaceConfig) + segment := strings.TrimSpace(entry.Provider) + if segment != "" </span><span class="cov1" title="2">{ + segment += ":" + }</span> + <span class="cov1" title="2">segment += strings.TrimSpace(entry.Model) + if entry.Temperature != nil </span><span class="cov0" title="0">{ + segment += fmt.Sprintf("@%.3f", *entry.Temperature) + }</span> + <span class="cov1" title="2">parts = append(parts, segment)</span> + } + <span class="cov1" title="2">return strings.Join(parts, "|")</span> + } + <span class="cov0" title="0">return fmt.Sprint(v.Interface())</span> + case reflect.Ptr:<span class="cov6" title="84"> + if v.IsNil() </span><span class="cov6" title="44">{ + return "(unset)" + }</span> + <span class="cov5" title="40">return stringifyValue(v.Elem())</span> + default:<span class="cov0" title="0"> + return fmt.Sprint(v.Interface())</span> + } +} + +// FormatSummary creates a human-readable summary for configuration changes. +func FormatSummary(prefix string, changes []Change) string <span class="cov3" title="7">{ + if len(changes) == 0 </span><span class="cov1" title="2">{ + return fmt.Sprintf("%s (no changes detected).", prefix) + }</span> + <span class="cov3" title="5">lines := make([]string, 0, len(changes)+1) + lines = append(lines, fmt.Sprintf("%s (%d changes):", prefix, len(changes))) + for _, ch := range changes </span><span class="cov3" title="6">{ + lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New)) + }</span> + <span class="cov3" title="5">return strings.Join(lines, "\n")</span> +} +</pre> + + <pre class="file" id="file39" style="display: none">//go:build !windows + +package stats + +import ( + "errors" + + "golang.org/x/sys/unix" +) + +func tryLockFile(fd uintptr) error <span class="cov10" title="582">{ + if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil </span><span class="cov9" title="448">{ + if errors.Is(err, unix.EWOULDBLOCK) </span><span class="cov9" title="448">{ + return errLockWouldBlock + }</span> + <span class="cov0" title="0">return err</span> + } + <span class="cov7" title="134">return nil</span> +} + +func unlockFile(fd uintptr) error <span class="cov7" title="134">{ + return unix.Flock(int(fd), unix.LOCK_UN) +}</span> +</pre> + + <pre class="file" id="file40" style="display: none">// Package stats provides a simple, process-safe, on-disk cache of Hexai LLM usage +// statistics shared across all binaries. It appends compact events (ts, provider, +// model, sent, recv) to a JSON file guarded by an advisory file lock, prunes +// entries older than the configured window (default 1h), and computes aggregated +// snapshots for display in logs and tmux status. +package stats + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync/atomic" + "time" +) + +const ( + fileName = "stats.json" + lockFileName = "stats.lock" + fileVersion = 1 + defaultWindow = time.Hour +) + +var windowSeconds int64 = int64(defaultWindow.Seconds()) + +var errLockWouldBlock = errors.New("stats: lock would block") + +// SetWindow sets the sliding window used for pruning and aggregation. +func SetWindow(d time.Duration) <span class="cov5" title="95">{ + if d < time.Second </span><span class="cov0" title="0">{ + d = time.Second + }</span> + <span class="cov5" title="95">if d > 24*time.Hour </span><span class="cov0" title="0">{ + d = 24 * time.Hour + }</span> + <span class="cov5" title="95">atomic.StoreInt64(&windowSeconds, int64(d.Seconds()))</span> +} + +// Window returns the current sliding window. +func Window() time.Duration <span class="cov5" title="134">{ return time.Duration(atomic.LoadInt64(&windowSeconds)) * time.Second }</span> + +// Event represents a single request/response with sizes. +type Event struct { + TS time.Time `json:"ts"` + Provider string `json:"provider"` + Model string `json:"model"` + Sent int64 `json:"sent"` + Recv int64 `json:"recv"` +} + +// File is the on-disk JSON structure. +type File struct { + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` + WindowSeconds int `json:"window_seconds"` + Events []Event `json:"events"` +} + +// Counters and Snapshot represent computed aggregates for the current window. +type Counters struct{ Reqs, Sent, Recv int64 } + +type ProviderEntry struct { + Totals Counters + Models map[string]Counters +} + +type Snapshot struct { + Global Counters + Providers map[string]ProviderEntry + RPM float64 + Window time.Duration +} + +// Update appends one event and prunes old entries under lock. +func Update(ctx context.Context, provider, model string, sentBytes, recvBytes int) error <span class="cov5" title="134">{ + dir, err := CacheDir() + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="134">if err := os.MkdirAll(dir, 0o755); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="134">lockPath := filepath.Join(dir, lockFileName) + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="134">defer func() </span><span class="cov5" title="134">{ _ = f.Close() }</span>() + <span class="cov5" title="134">unlock, err := acquireFileLock(ctx, f) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="134">defer func() </span><span class="cov5" title="134">{ _ = unlock() }</span>() + // Read existing file (if any) + <span class="cov5" title="134">path := filepath.Join(dir, fileName) + var sf File + if b, rerr := os.ReadFile(path); rerr == nil </span><span class="cov5" title="125">{ + _ = json.Unmarshal(b, &sf) + }</span> + <span class="cov5" title="134">if sf.Version != fileVersion </span><span class="cov3" title="9">{ + sf = File{Version: fileVersion} + }</span> + <span class="cov5" title="134">now := time.Now() + win := Window() + sf.WindowSeconds = int(win.Seconds()) + // Append event + sf.Events = append(sf.Events, Event{TS: now, Provider: provider, Model: model, Sent: int64(sentBytes), Recv: int64(recvBytes)}) + // Prune old + cutoff := now.Add(-win) + if len(sf.Events) > 0 </span><span class="cov5" title="134">{ + // Find first >= cutoff + i := 0 + for ; i < len(sf.Events); i++ </span><span class="cov6" title="267">{ + if !sf.Events[i].TS.Before(cutoff) </span><span class="cov5" title="134">{ + break</span> + } + } + <span class="cov5" title="134">if i > 0 </span><span class="cov2" title="4">{ + sf.Events = append([]Event(nil), sf.Events[i:]...) + }</span> + } + <span class="cov5" title="134">sf.UpdatedAt = now + // Write atomically + tmp, err := os.CreateTemp(dir, fileName+".tmp.") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov5" title="134">enc := json.NewEncoder(tmp) + enc.SetEscapeHTML(false) + if err := enc.Encode(&sf); err != nil </span><span class="cov0" title="0">{ + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + }</span> + <span class="cov5" title="134">if err := tmp.Sync(); err != nil </span><span class="cov0" title="0">{ + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + }</span> + <span class="cov5" title="134">if err := tmp.Close(); err != nil </span><span class="cov0" title="0">{ + _ = os.Remove(tmp.Name()) + return err + }</span> + <span class="cov5" title="134">if err := os.Rename(tmp.Name(), path); err != nil </span><span class="cov0" title="0">{ + _ = os.Remove(tmp.Name()) + return err + }</span> + <span class="cov5" title="134">return nil</span> +} + +func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) <span class="cov5" title="134">{ + fd := f.Fd() + for </span><span class="cov6" title="582">{ + err := tryLockFile(fd) + if err == nil </span><span class="cov5" title="134">{ + return func() error </span><span class="cov5" title="134">{ return unlockFile(fd) }</span>, nil + } + <span class="cov6" title="448">if errors.Is(err, errLockWouldBlock) </span><span class="cov6" title="448">{ + select </span>{ + case <-ctx.Done():<span class="cov0" title="0"> + return nil, ctx.Err()</span> + case <-time.After(5 * time.Millisecond):<span class="cov6" title="448"></span> + } + <span class="cov6" title="448">continue</span> + } + <span class="cov0" title="0">return nil, err</span> + } +} + +// Snapshot reads and aggregates events within the configured window. +func TakeSnapshot() (Snapshot, error) <span class="cov5" title="76">{ + dir, err := CacheDir() + if err != nil </span><span class="cov0" title="0">{ + return Snapshot{}, err + }</span> + <span class="cov5" title="76">path := filepath.Join(dir, fileName) + b, err := os.ReadFile(path) + if err != nil </span><span class="cov0" title="0">{ + if errors.Is(err, os.ErrNotExist) </span><span class="cov0" title="0">{ + return Snapshot{Providers: map[string]ProviderEntry{}, Window: Window()}, nil + }</span> + <span class="cov0" title="0">return Snapshot{}, err</span> + } + <span class="cov5" title="76">var sf File + if err := json.Unmarshal(b, &sf); err != nil </span><span class="cov0" title="0">{ + return Snapshot{}, err + }</span> + <span class="cov5" title="76">win := time.Duration(sf.WindowSeconds) * time.Second + if win <= 0 </span><span class="cov0" title="0">{ + win = Window() + }</span> else<span class="cov5" title="76"> { + SetWindow(win) // align process with file window if changed elsewhere + }</span> + <span class="cov5" title="76">cutoff := time.Now().Add(-win) + snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} + for _, ev := range sf.Events </span><span class="cov10" title="15367">{ + if ev.TS.Before(cutoff) </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov10" title="15367">snap.Global.Reqs++ + snap.Global.Sent += ev.Sent + snap.Global.Recv += ev.Recv + pe := snap.Providers[ev.Provider] + if pe.Models == nil </span><span class="cov6" title="478">{ + pe.Models = make(map[string]Counters) + }</span> + <span class="cov10" title="15367">pe.Totals.Reqs++ + pe.Totals.Sent += ev.Sent + pe.Totals.Recv += ev.Recv + mc := pe.Models[ev.Model] + mc.Reqs++ + mc.Sent += ev.Sent + mc.Recv += ev.Recv + pe.Models[ev.Model] = mc + snap.Providers[ev.Provider] = pe</span> + } + <span class="cov5" title="76">mins := win.Minutes() + if mins <= 0 </span><span class="cov0" title="0">{ + mins = 0.001 + }</span> + <span class="cov5" title="76">snap.RPM = float64(snap.Global.Reqs) / mins + return snap, nil</span> +} + +// CacheDir resolves the cache directory for stats. +func CacheDir() (string, error) <span class="cov6" title="213">{ + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" </span><span class="cov5" title="81">{ + return filepath.Join(x, "hexai"), nil + }</span> + <span class="cov5" title="132">home, err := os.UserHomeDir() + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("cannot resolve home: %w", err) + }</span> + <span class="cov5" title="132">return filepath.Join(home, ".cache", "hexai"), nil</span> +} + +// stringsTrim is a tiny helper to avoid importing strings everywhere here. +func stringsTrim(s string) string <span class="cov6" title="213">{ + i := 0 + j := len(s) + for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ + i++ + }</span> + <span class="cov6" title="213">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ + j-- + }</span> + <span class="cov6" title="213">if i == 0 && j == len(s) </span><span class="cov6" title="213">{ + return s + }</span> + <span class="cov0" title="0">return s[i:j]</span> +} + +// DebugString returns a compact single-line view of a snapshot (useful for logs). +func (s Snapshot) DebugString() string <span class="cov2" title="3">{ + return "Σ reqs=" + strconv.FormatInt(s.Global.Reqs, 10) + " rpm=" + fmt.Sprintf("%.2f", s.RPM) +}</span> +</pre> + + <pre class="file" id="file41" style="display: none">package testutil + +// MultilineDocBlock returns a realistic multi-line documentation block. +func MultilineDocBlock() string <span class="cov1" title="1">{ + return "// add adds two numbers\n// returns their sum" +}</span> + +// MultilineChatReply returns a multi-line assistant reply for chat tests. +func MultilineChatReply() string <span class="cov1" title="1">{ + return "Hello, world!\nThis is a multi-line reply." +}</span> + +// MultilineFunctionSuggestion returns a more realistic multi-line function body suggestion. +func MultilineFunctionSuggestion() string <span class="cov1" title="1">{ + return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" +}</span> + +// MarkdownCodeFence returns a fenced markdown snippet used in post-processing tests. +func MarkdownCodeFence() string <span class="cov10" title="3">{ + return "```go\nname := value\n```" +}</span> + +// MalformedJSON returns a deliberately malformed JSON string. +func MalformedJSON() string <span class="cov10" title="3">{ + return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" +}</span> +</pre> + + <pre class="file" id="file42" style="display: none">package textutil + +import "fmt" + +// HumanBytes renders n in a short human-friendly form using base-1000 units. +// Examples: 999 -> 999B, 1200 -> 1.2k, 1540000 -> 1.5M +func HumanBytes(n int64) string <span class="cov10" title="154">{ + if n < 1000 </span><span class="cov4" title="6">{ + return fmt.Sprintf("%dB", n) + }</span> + <span class="cov9" title="148">const unit = 1000.0 + v := float64(n) + suffix := []string{"k", "M", "G", "T"} + i := 0 + for v >= unit && i < len(suffix)-1 </span><span class="cov9" title="148">{ + v /= unit + i++ + }</span> + <span class="cov9" title="148">s := fmt.Sprintf("%.1f%s", v, suffix[i]) + // Strip trailing ".0" + if len(s) >= 3 && s[len(s)-2:] == ".0" </span><span class="cov0" title="0">{ + s = fmt.Sprintf("%d%s", int(v), suffix[i]) + }</span> + <span class="cov9" title="148">return s</span> +} +</pre> + + <pre class="file" id="file43" style="display: none">package textutil + +import "strings" + +// RenderTemplate performs simple {{var}} replacement in a template string. +func RenderTemplate(t string, vars map[string]string) string <span class="cov8" title="72">{ + if t == "" || len(vars) == 0 </span><span class="cov4" title="11">{ + return t + }</span> + <span class="cov7" title="61">out := t + for k, v := range vars </span><span class="cov9" title="159">{ + out = strings.ReplaceAll(out, "{{"+k+"}}", v) + }</span> + <span class="cov7" title="61">return out</span> +} + +// StripCodeFences removes surrounding Markdown triple-backtick fences. +func StripCodeFences(s string) string <span class="cov8" title="82">{ + t := strings.TrimSpace(s) + if t == "" </span><span class="cov1" title="1">{ + return t + }</span> + <span class="cov8" title="81">lines := strings.Split(t, "\n") + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{ + start++ + }</span> + <span class="cov8" title="81">end := len(lines) - 1 + for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ + end-- + }</span> + <span class="cov8" title="81">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + return t + }</span> + <span class="cov8" title="81">first := strings.TrimSpace(lines[start]) + last := strings.TrimSpace(lines[end]) + if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="26">{ + inner := strings.Join(lines[start+1:end], "\n") + return inner + }</span> + <span class="cov7" title="55">return t</span> +} + +// 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) <span class="cov6" title="26">{ + lines := strings.Split(sel, "\n") + for idx, line := range lines </span><span class="cov6" title="26">{ + if instr, cleaned, ok := FindFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov6" title="26">{ + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + }</span> + } + <span class="cov0" title="0">return "", sel</span> +} + +// FindFirstInstructionInLine returns (instruction, cleaned, ok) for a single line. +func FindFirstInstructionInLine(line string) (instr, cleaned string, ok bool) <span class="cov6" title="29">{ + type cand struct { + start, end int + text string + } + cands := []cand{} + if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov4" title="9">{ + cands = append(cands, cand{start: l, end: r, text: t}) + }</span> + <span class="cov6" title="29">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov3" title="4">{ + if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov3" title="4">{ + 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}) + }</span> + } + <span class="cov6" title="29">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov3" title="4">{ + if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov3" title="4">{ + 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}) + }</span> + } + <span class="cov6" title="29">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="7">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov6" title="29">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov3" title="4">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + }</span> + <span class="cov6" title="29">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov4" title="8">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov6" title="29">if len(cands) == 0 </span><span class="cov0" title="0">{ + return "", line, false + }</span> + <span class="cov6" title="29">best := cands[0] + for _, c := range cands[1:] </span><span class="cov4" title="7">{ + if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov0" title="0">{ + best = c + }</span> + } + <span class="cov6" title="29">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true</span> +} + +// FindStrictInlineTag finds ;text; with no spaces after/before semicolons. +func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <span class="cov6" title="35">{ + for i := 0; i < len(line); i++ </span><span class="cov10" title="229">{ + if line[i] != ';' </span><span class="cov9" title="211">{ + continue</span> + } + <span class="cov5" title="18">if i+1 < len(line) && line[i+1] == ' ' </span><span class="cov2" title="3">{ + continue</span> + } + <span class="cov5" title="15">for j := i + 1; j < len(line); j++ </span><span class="cov8" title="71">{ + if line[j] == ';' </span><span class="cov5" title="12">{ + if j-1 >= 0 && line[j-1] == ' ' </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov5" title="12">inner := strings.TrimSpace(line[i+1 : j]) + if inner != "" </span><span class="cov5" title="12">{ + return inner, i, j + 1, true + }</span> + } + } + } + <span class="cov6" title="23">return "", -1, -1, false</span> +} +</pre> + + <pre class="file" id="file44" style="display: none">package tmux + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/textutil" +) + +// baseFGToken is a placeholder inserted by status formatters wherever the +// base foreground color should be restored. The theming layer (applyTheme) +// replaces this token with a tmux color sequence matching the active theme's +// foreground, which fixes readability when a theme sets a non-default fg. +const ( + baseFGToken = "\x1EHEXAI_BASE_FG\x1E" + arrowUpToken = "\x1EHEXAI_ARROW_UP\x1E" + arrowDownToken = "\x1EHEXAI_ARROW_DOWN\x1E" +) + +// Enabled reports whether tmux status updates are enabled via env (default: on). +func Enabled() bool <span class="cov7" title="77">{ + v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) + if v == "" </span><span class="cov7" title="74">{ + return true + }</span> + <span class="cov2" title="3">v = strings.ToLower(v) + return v == "1" || v == "true" || v == "yes" || v == "on"</span> +} + +// SetUserOption sets a global tmux user option like @hexai_status to value. +func SetUserOption(key, value string) error <span class="cov7" title="77">{ + if !Enabled() || !HasBinary() || !InSession() </span><span class="cov2" title="3">{ + return nil + }</span> + <span class="cov7" title="74">k := strings.TrimPrefix(strings.TrimSpace(key), "@") + if k == "" </span><span class="cov0" title="0">{ + return nil + }</span> + // Use set-option -g so it appears for all windows + <span class="cov7" title="74">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span> +} + +// SetStatus is a convenience for setting @hexai_status. +func SetStatus(value string) error <span class="cov7" title="77">{ return SetUserOption("hexai_status", applyTheme(value)) }</span> + +// FormatLLMStatsStatus builds a compact tmux status string for LLM heartbeats. +// Example: "LLM:gpt-4.1 5r 0.8rpm in12k out34k" +func FormatLLMStatsStatus(model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov2" title="3">{ + return fmt.Sprintf("LLM:%s %dr %.1frpm in%s out%s", model, reqs, rpm, textutil.HumanBytes(inBytes), textutil.HumanBytes(outBytes)) +}</span> + +// FormatLLMStatsStatusColored is like FormatLLMStatsStatus but includes provider and +// tmux color segments for readability. Uses up/down arrows for bytes. +// Example (with colors): "LLM:openai:gpt-4.1 ↑12k ↓34k 0.8rpm 5r" +func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64, inBytes, outBytes int64) string <span class="cov2" title="3">{ + in := textutil.HumanBytes(inBytes) + out := textutil.HumanBytes(outBytes) + // Keep it compact; colorize prefix and arrows; use fg resets so a themed bg can persist. + // Arrows use theme-aware styles; bytes immediately switch to base fg for contrast. + return fmt.Sprintf( + "%sLLM:%s:%s %s↑%s%s %s↓%s%s %.1frpm %dr", + baseFGToken, provider, model, arrowUpToken, baseFGToken, in, arrowDownToken, baseFGToken, out, rpm, reqs, + ) +}</span> + +// FormatGlobalStatusColored renders a compact global stats heartbeat with an optional +// scoped provider:model tail. The window indicator (e.g., Σ@1h) should be composed +// by the caller if needed; this function focuses on numbers and labels. +// Example: "Σ ↑120k ↓340k 4.2rpm | openai:gpt-4.1 3.1rpm 80r" +func FormatGlobalStatusColored(globalReqs int64, globalRPM float64, globalIn, globalOut int64, scopeProvider, scopeModel string, scopeRPM float64, scopeReqs int64, window time.Duration) string <span class="cov7" title="71">{ + gin := textutil.HumanBytes(globalIn) + gout := textutil.HumanBytes(globalOut) + head := fmt.Sprintf("%sΣ@%s %s↑%s%s %s↓%s%s %.1frpm", baseFGToken, humanWindow(window), arrowUpToken, baseFGToken, gin, arrowDownToken, baseFGToken, gout, globalRPM) + // Narrow modes: only show Σ head + if narrowEnabled() || stringsTrim(scopeProvider) == "" || stringsTrim(scopeModel) == "" </span><span class="cov2" title="3">{ + return head + }</span> + <span class="cov7" title="68">tail := fmt.Sprintf(" | %s:%s %.1frpm %dr", scopeProvider, scopeModel, scopeRPM, scopeReqs) + // Respect max length when configured: drop tail if it would overflow + if ml := maxStatusLen(); ml > 0 </span><span class="cov2" title="3">{ + if len(head) <= ml && len(head)+len(tail) > ml </span><span class="cov0" title="0">{ + return head + }</span> + <span class="cov2" title="3">if len(head) > ml </span><span class="cov2" title="3">{ + return truncateStatus(head, ml) + }</span> + } + <span class="cov7" title="65">return head + tail</span> +} + +func humanWindow(d time.Duration) string <span class="cov7" title="71">{ + if d <= 0 </span><span class="cov0" title="0">{ + return "?" + }</span> + <span class="cov7" title="71">mins := int(d.Minutes()) + if mins%60 == 0 </span><span class="cov7" title="65">{ + return fmt.Sprintf("%dh", mins/60) + }</span> + <span class="cov3" title="6">if mins >= 60 </span><span class="cov0" title="0">{ + return fmt.Sprintf("%dm", mins) + }</span> + <span class="cov3" title="6">return fmt.Sprintf("%dm", mins)</span> +} + +// narrowEnabled returns true when HEXAI_TMUX_STATUS_NARROW is truthy (1/true/yes/on). +func narrowEnabled() bool <span class="cov7" title="71">{ + v := strings.ToLower(stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_NARROW"))) + if v == "" </span><span class="cov7" title="68">{ + return false + }</span> + <span class="cov2" title="3">switch v </span>{ + case "1", "true", "yes", "on":<span class="cov2" title="3"> + return true</span> + default:<span class="cov0" title="0"> + return false</span> + } +} + +// maxStatusLen returns HEXAI_TMUX_STATUS_MAXLEN parsed as int; 0 disables. +func maxStatusLen() int <span class="cov7" title="68">{ + v := stringsTrim(os.Getenv("HEXAI_TMUX_STATUS_MAXLEN")) + if v == "" </span><span class="cov7" title="65">{ + return 0 + }</span> + <span class="cov2" title="3">n, err := strconv.Atoi(v) + if err != nil || n <= 0 </span><span class="cov0" title="0">{ + return 0 + }</span> + <span class="cov2" title="3">return n</span> +} + +func truncateStatus(s string, n int) string <span class="cov2" title="3">{ + if n <= 0 </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov2" title="3">if len(s) <= n </span><span class="cov0" title="0">{ + return s + }</span> + <span class="cov2" title="3">if n <= 1 </span><span class="cov0" title="0">{ + return s[:n] + }</span> + <span class="cov2" title="3">return s[:n-1] + "…"</span> +} + +func stringsTrim(s string) string <span class="cov10" title="275">{ + i := 0 + j := len(s) + for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') </span><span class="cov0" title="0">{ + i++ + }</span> + <span class="cov10" title="275">for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') </span><span class="cov0" title="0">{ + j-- + }</span> + <span class="cov10" title="275">if i == 0 && j == len(s) </span><span class="cov10" title="275">{ + return s + }</span> + <span class="cov0" title="0">return s[i:j]</span> +} + +// FormatLLMStartStatus renders a short colored heartbeat at start/initialize time. +// Example: "LLM:openai:gpt-4.1 ⏳" +func FormatLLMStartStatus(provider, model string) string <span class="cov4" title="12">{ + return fmt.Sprintf("%sLLM:%s:%s #[fg=colour11]⏳%s", baseFGToken, provider, model, baseFGToken) +}</span> + +// applyTheme wraps the status string with a user-selected tmux style if requested. +// Set HEXAI_TMUX_STATUS_THEME=white-on-purple to get white-on-purple background. +func applyTheme(s string) string <span class="cov7" title="77">{ + theme := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_THEME"))) + // Allow explicit fg/bg override + fg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_FG")) + bg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_BG")) + // Determine base foreground and background from env or theme presets + baseFG := "" + wrap := false + if fg != "" || bg != "" </span><span class="cov0" title="0">{ // explicit override path + wrap = true + if fg == "" </span><span class="cov0" title="0">{ + baseFG = "default" + }</span> else<span class="cov0" title="0"> { + baseFG = fg + }</span> + // bg used as provided (may be empty) + } else<span class="cov7" title="77"> { + switch theme </span>{ + case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov7" title="77"> + baseFG, bg, wrap = "white", "magenta", true</span> + case "black-on-yellow", "yellow", "black-on-gold":<span class="cov0" title="0"> + baseFG, bg, wrap = "black", "yellow", true</span> + case "white-on-blue", "blue", "white-on-navy":<span class="cov0" title="0"> + baseFG, bg, wrap = "white", "blue", true</span> + } + <span class="cov7" title="77">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected + baseFG = "default" + }</span> + } + + // Theme-aware arrow styles + <span class="cov7" title="77">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down + if fg != "" || bg != "" </span><span class="cov7" title="77">{ // explicit override path: match arrows to base fg, bold for visibility + upStyle = "#[bold,fg=" + baseFG + "]" + downStyle = upStyle + }</span> else<span class="cov0" title="0"> { + switch theme </span>{ + case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov0" title="0"> + upStyle, downStyle = "#[bold,fg=black]", "#[bold,fg=black]"</span> + case "black-on-yellow", "yellow", "black-on-gold":<span class="cov0" title="0"> + upStyle, downStyle = "#[bold,fg=black]", "#[bold,fg=black]"</span> + case "white-on-blue", "blue", "white-on-navy":<span class="cov0" title="0"> + upStyle, downStyle = "#[bold,fg=white]", "#[bold,fg=white]"</span> + } + } + + // Replace base-foreground and arrow placeholders with selected styles + <span class="cov7" title="77">if strings.Contains(s, baseFGToken) </span><span class="cov7" title="77">{ + s = strings.ReplaceAll(s, baseFGToken, "#[fg="+baseFG+"]") + }</span> + <span class="cov7" title="77">if strings.Contains(s, arrowUpToken) </span><span class="cov7" title="65">{ + s = strings.ReplaceAll(s, arrowUpToken, upStyle) + }</span> + <span class="cov7" title="77">if strings.Contains(s, arrowDownToken) </span><span class="cov7" title="65">{ + s = strings.ReplaceAll(s, arrowDownToken, downStyle) + }</span> + + <span class="cov7" title="77">if !wrap </span><span class="cov0" title="0">{ + return s + }</span> + // Wrap with base fg and optional bg, then reset at the end + <span class="cov7" title="77">prefix := "#[fg=" + baseFG + if bg != "" </span><span class="cov7" title="77">{ + prefix += ",bg=" + bg + }</span> + <span class="cov7" title="77">prefix += "]" + return prefix + s + "#[fg=default,bg=default]"</span> +} +</pre> + + <pre class="file" id="file45" style="display: none">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 <span class="cov4" title="6">{ return HasBinary() && InSession() }</span> + +// HasBinary reports whether the tmux binary is on PATH. +var ( + lookPath = exec.LookPath + command = exec.Command +) + +func HasBinary() bool <span class="cov10" title="86">{ _, err := lookPath("tmux"); return err == nil }</span> + +// InSession reports whether we seem to be running inside a tmux session. +func InSession() bool <span class="cov9" title="83">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> + +// 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 <span class="cov3" title="3">{ + if len(argv) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov3" title="3">args := []string{"split-window"} + if opts.Vertical </span><span class="cov3" title="3">{ + args = append(args, "-v") + }</span> else<span class="cov0" title="0"> { + args = append(args, "-h") + }</span> + <span class="cov3" title="3">if opts.Percent > 0 && opts.Percent <= 100 </span><span class="cov3" title="3">{ + args = append(args, "-p", strconv.Itoa(opts.Percent)) + }</span> + <span class="cov3" title="3">if strings.TrimSpace(opts.Target) != "" </span><span class="cov3" title="3">{ + args = append(args, "-t", opts.Target) + }</span> + // tmux takes a single command string. Use a conservative shell join. + <span class="cov3" title="3">cmdStr := shellJoin(argv) + args = append(args, cmdStr) + c := command("tmux", args...) + return c.Run()</span> +} + +// 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 <span class="cov3" title="3">{ + out := make([]string, 0, len(argv)) + for _, a := range argv </span><span class="cov6" title="12">{ + if a == "" </span><span class="cov0" title="0">{ + out = append(out, "''") + continue</span> + } + <span class="cov6" title="12">if isSafeBare(a) </span><span class="cov4" title="6">{ + out = append(out, a) + continue</span> + } + // single-quote wrapping with escaped single quotes + // ' => '\'' (close, escaped quote, reopen) + <span class="cov4" title="6">esc := strings.ReplaceAll(a, "'", "'\\''") + out = append(out, "'"+esc+"'")</span> + } + <span class="cov3" title="3">return strings.Join(out, " ")</span> +} + +// isSafeBare returns true if a contains only safe characters for bare words. +func isSafeBare(s string) bool <span class="cov6" title="12">{ + for i := 0; i < len(s); i++ </span><span class="cov9" title="81">{ + b := s[i] + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov9" title="75">{ + continue</span> + } + <span class="cov4" title="6">return false</span> + } + <span class="cov4" title="6">return true</span> +} +</pre> + + <pre class="file" id="file46" style="display: none">// Package tmuxedit implements a tmux popup editor for composing AI agent prompts. +// agent.go defines the Agent interface, the baseAgent struct with default +// implementations, and agent detection/resolution helpers. +package tmuxedit + +import ( + "regexp" + "strings" +) + +// Agent defines how to interact with a specific AI agent in a tmux pane. +// Each implementation encapsulates its own detection, extraction, clearing, +// and sending logic since agents differ fundamentally in their UI structure. +type Agent interface { + Name() string + DisplayName() string + Detect(paneContent string) bool + ExtractPrompt(paneContent string) string + ClearInput(paneID string) error + SendText(paneID, text string) error +} + +// Configurable provides access to a baseAgent's fields for config merging. +// Agent implementations that embed baseAgent automatically satisfy this. +type Configurable interface { + Base() *baseAgent +} + +// baseAgent holds configurable fields and provides default implementations +// of the Agent interface. Specialized agents (cursor, claude) embed baseAgent +// and override methods where behavior differs from the defaults. +type baseAgent struct { + name string + displayName string + detectPattern string + sectionPat string // optional regex to delimit the prompt area + promptPat string // regex with capture group (1) for prompt text + stripPatterns []string // substrings removed from extracted text + clearFirst bool // whether to clear existing input before sending + clearKeys string // tmux key sequence to clear input + newlineKeys string // tmux key to insert a newline + submitKeys string // tmux key to submit the prompt +} + +// Base returns a pointer to the baseAgent for config merging. +func (b *baseAgent) Base() *baseAgent <span class="cov5" title="10">{ return b }</span> + +// Name returns the agent's short identifier (e.g. "claude", "cursor"). +func (b *baseAgent) Name() string <span class="cov10" title="65">{ return b.name }</span> + +// DisplayName returns the agent's human-readable name. +func (b *baseAgent) DisplayName() string <span class="cov3" title="3">{ return b.displayName }</span> + +// Detect checks whether the pane content matches this agent's detection +// pattern. Returns false if no pattern is set or the regex is invalid. +func (b *baseAgent) Detect(paneContent string) bool <span class="cov9" title="48">{ + if b.detectPattern == "" </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov9" title="48">re, err := regexp.Compile(b.detectPattern) + if err != nil </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov9" title="47">return re.MatchString(paneContent)</span> +} + +// ExtractPrompt uses the agent's prompt pattern to extract the current prompt +// text from pane content. If sectionPat is set, extraction is scoped to the +// last section between two delimiter lines and all matches are joined. +// Without sectionPat, the last contiguous group of matched lines is used. +// Returns empty string if no pattern or no match. +func (b *baseAgent) ExtractPrompt(paneContent string) string <span class="cov4" title="6">{ + if b.promptPat == "" </span><span class="cov3" title="3">{ + return "" + }</span> + <span class="cov3" title="3">re, err := regexp.Compile(b.promptPat) + if err != nil </span><span class="cov1" title="1">{ + return "" + }</span> + <span class="cov2" title="2">scoped := b.sectionPat != "" + content := scopeToLastSection(paneContent, b.sectionPat) + allMatches := matchPromptLines(re, content) + if len(allMatches) == 0 </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov2" title="2">if scoped </span><span class="cov0" title="0">{ + return joinAllMatches(allMatches, b.stripPatterns) + }</span> + <span class="cov2" title="2">return joinLastContiguousBlock(allMatches, b.stripPatterns)</span> +} + +// ClearInput clears existing input in the pane using the configured key +// sequence. Skipped if clearFirst is false or clearKeys is empty. +func (b *baseAgent) ClearInput(paneID string) error <span class="cov2" title="2">{ + if !b.clearFirst || b.clearKeys == "" </span><span class="cov2" title="2">{ + return nil + }</span> + <span class="cov0" title="0">if err := sendClearSequence(paneID, b.clearKeys); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">sleepAfterClear() + return nil</span> +} + +// SendText sends the given text to the target pane line-by-line, using the +// agent's newline key between lines. +func (b *baseAgent) SendText(paneID, text string) error <span class="cov3" title="4">{ + if strings.TrimSpace(text) == "" </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov3" title="3">return sendLines(paneID, text, b.newlineKeys)</span> +} + +// detectAgent tries each agent's Detect method against pane content. +// First match wins. Returns genericAgent() if no agent matches. +func detectAgent(paneContent string, agents []Agent) Agent <span class="cov6" title="15">{ + for _, a := range agents </span><span class="cov8" title="39">{ + if a.Detect(paneContent) </span><span class="cov5" title="10">{ + return a + }</span> + } + <span class="cov4" title="5">return genericAgent()</span> +} + +// findAgentByName returns the agent with the given name (case-insensitive), +// falling back to genericAgent() if not found. +func findAgentByName(name string, agents []Agent) Agent <span class="cov5" title="7">{ + for _, a := range agents </span><span class="cov6" title="14">{ + if strings.EqualFold(a.Name(), name) </span><span class="cov4" title="6">{ + return a + }</span> + } + <span class="cov1" title="1">return genericAgent()</span> +} +</pre> + + <pre class="file" id="file47" style="display: none">// Package tmuxedit implements a tmux popup editor for composing AI agent prompts. +// agentutil.go provides shared helpers for prompt extraction and tmux key sending +// used by individual agent implementations. +package tmuxedit + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// promptMatch holds a regex match result with its line number in the pane. +type promptMatch struct { + lineNum int + text string // capture group 1 +} + +// matchPromptLines runs the prompt regex against each pane line, returning +// matches with their line numbers for contiguity analysis. +func matchPromptLines(re *regexp.Regexp, paneContent string) []promptMatch <span class="cov6" title="14">{ + paneLines := strings.Split(paneContent, "\n") + var matches []promptMatch + for i, line := range paneLines </span><span class="cov9" title="42">{ + m := re.FindStringSubmatch(line) + if len(m) >= 2 </span><span class="cov7" title="22">{ + matches = append(matches, promptMatch{lineNum: i, text: m[1]}) + }</span> + } + <span class="cov6" title="14">return matches</span> +} + +// joinAllMatches strips noise from all matches and joins the non-empty results +// with newlines. Used when SectionPattern has already scoped to the prompt area. +func joinAllMatches(matches []promptMatch, strips []string) string <span class="cov1" title="1">{ + var lines []string + for _, m := range matches </span><span class="cov3" title="3">{ + line := stripNoise(m.text, strips) + if line != "" </span><span class="cov2" title="2">{ + lines = append(lines, line) + }</span> + } + <span class="cov1" title="1">return strings.Join(lines, "\n")</span> +} + +// joinLastContiguousBlock takes the last group of matches on consecutive line +// numbers, strips noise from each, and joins the non-empty results with +// newlines. This ensures that only the bottom-most box (the input prompt) +// is captured when multiple box-drawing sections exist in the pane. +func joinLastContiguousBlock(matches []promptMatch, strips []string) string <span class="cov6" title="12">{ + last := len(matches) - 1 + start := last + for start > 0 && matches[start].lineNum-matches[start-1].lineNum == 1 </span><span class="cov5" title="7">{ + start-- + }</span> + <span class="cov6" title="12">var lines []string + for i := start; i <= last; i++ </span><span class="cov7" title="19">{ + line := stripNoise(matches[i].text, strips) + if line != "" </span><span class="cov7" title="18">{ + lines = append(lines, line) + }</span> + } + <span class="cov6" title="12">return strings.Join(lines, "\n")</span> +} + +// scopeToLastSection extracts the content between the last two lines matching +// the section delimiter pattern. This isolates the prompt area (e.g. Claude's +// ─── rules) from previous conversation content. Returns the full content if +// no pattern is set or fewer than two delimiters are found. +func scopeToLastSection(paneContent, sectionPattern string) string <span class="cov7" title="16">{ + if sectionPattern == "" </span><span class="cov3" title="3">{ + return paneContent + }</span> + <span class="cov6" title="13">re, err := regexp.Compile(sectionPattern) + if err != nil </span><span class="cov1" title="1">{ + return paneContent + }</span> + <span class="cov6" title="12">lines := strings.Split(paneContent, "\n") + var delimLines []int + for i, line := range lines </span><span class="cov10" title="58">{ + if re.MatchString(line) </span><span class="cov8" title="24">{ + delimLines = append(delimLines, i) + }</span> + } + <span class="cov6" title="12">if len(delimLines) < 2 </span><span class="cov4" title="4">{ + return paneContent + }</span> + <span class="cov5" title="8">start := delimLines[len(delimLines)-2] + 1 + end := delimLines[len(delimLines)-1] + if start >= end </span><span class="cov0" title="0">{ + return paneContent + }</span> + <span class="cov5" title="8">return strings.Join(lines[start:end], "\n")</span> +} + +// stripNoise removes each of the agent's StripPatterns from text and trims +// whitespace. +func stripNoise(text string, patterns []string) string <span class="cov8" title="35">{ + for _, p := range patterns </span><span class="cov9" title="44">{ + text = strings.ReplaceAll(text, p, "") + }</span> + <span class="cov8" title="35">return strings.TrimSpace(text)</span> +} + +// sendClearSequence parses a space-separated key sequence and sends each +// token individually. Tokens with a "*N" suffix (e.g. "BSpace*200") are +// sent N times using tmux send-keys -N for efficient bulk repeats. +func sendClearSequence(paneID, clearKeys string) error <span class="cov4" title="4">{ + for _, token := range strings.Fields(clearKeys) </span><span class="cov7" title="16">{ + key, count := parseKeyRepeat(token) + if count > 1 </span><span class="cov2" title="2">{ + if err := sendRepeatedKey(paneID, key, count); err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("clear key %q*%d failed: %w", key, count, err) + }</span> + } else<span class="cov6" title="14"> { + if err := sendKeys(paneID, key); err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("clear key %q failed: %w", key, err) + }</span> + } + } + <span class="cov4" title="4">return nil</span> +} + +// parseKeyRepeat splits "Key*N" into (Key, N). Returns (token, 1) if no +// repeat suffix is present or the suffix is invalid. +func parseKeyRepeat(token string) (string, int) <span class="cov8" title="24">{ + idx := strings.LastIndex(token, "*") + if idx < 1 || idx >= len(token)-1 </span><span class="cov7" title="17">{ + return token, 1 + }</span> + <span class="cov5" title="7">n, err := strconv.Atoi(token[idx+1:]) + if err != nil || n < 1 </span><span class="cov2" title="2">{ + return token, 1 + }</span> + <span class="cov4" title="5">return token[:idx], n</span> +} + +// sendLines sends text line-by-line to a tmux pane, inserting the specified +// newline key between lines. If newlineKeys is empty, "Enter" is used as +// fallback. This is the shared text-sending logic used by agent SendText +// implementations. +func sendLines(paneID, text, newlineKeys string) error <span class="cov5" title="7">{ + lines := strings.Split(text, "\n") + for i, line := range lines </span><span class="cov6" title="11">{ + if err := sendKeys(paneID, line); err != nil </span><span class="cov1" title="1">{ + return fmt.Errorf("send line %d failed: %w", i, err) + }</span> + // Insert inter-line newline (except after the last line) + <span class="cov6" title="10">if i < len(lines)-1 </span><span class="cov4" title="4">{ + nlKey := newlineKeys + if nlKey == "" </span><span class="cov1" title="1">{ + nlKey = "Enter" + }</span> + <span class="cov4" title="4">if err := sendKeys(paneID, nlKey); err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("newline after line %d failed: %w", i, err) + }</span> + } + } + <span class="cov4" title="6">return nil</span> +} +</pre> + + <pre class="file" id="file48" style="display: none">package tmuxedit + +import ( + "fmt" + "strings" +) + +// capturePane retrieves the visible content of a tmux pane via +// `tmux capture-pane -p -t <paneID>`. The -p flag prints to stdout +// instead of to a paste buffer. +var capturePane = func(paneID string) (string, error) <span class="cov10" title="3">{ + out, err := runCommand("tmux", "capture-pane", "-p", "-t", paneID) + if err != nil </span><span class="cov1" title="1">{ + return "", fmt.Errorf("capture-pane failed for %s: %w", paneID, err) + }</span> + <span class="cov6" title="2">return strings.TrimRight(string(out), "\n"), nil</span> +} +</pre> + + <pre class="file" id="file49" style="display: none">package tmuxedit + +import ( + "regexp" + "strings" +) + +// claudeAgent handles Claude Code's ❯ prompt between ──── horizontal rules. +// Claude Code runs in actual vim mode, so clearing uses vim commands. +// Wrapped text appears as indented continuation lines without ❯. +type claudeAgent struct{ baseAgent } + +// newClaudeAgent returns a claudeAgent with the default configuration. +// SectionPattern scopes extraction to the last ─── delimited area, avoiding +// false positives from ❯ in previous messages. +func newClaudeAgent() *claudeAgent <span class="cov10" title="18">{ + return &claudeAgent{baseAgent{ + name: "claude", + displayName: "Claude Code", + detectPattern: `(❯|(?i)claude code|(?i)anthropic)`, + sectionPat: `^─{5,}`, + promptPat: `(?m)❯\s*(.+)$`, + clearFirst: true, + clearKeys: "Escape gg C-v G d i", + newlineKeys: "S-Enter", + submitKeys: "Enter", + }} +}</span> + +// ExtractPrompt extracts the prompt text from the last section between ───── +// rules. Within the scoped section, all non-empty lines are collected: +// ❯-prefixed lines have the prefix stripped, and indented continuation lines +// (wrapped text without ❯) are included as-is after trimming. +func (c *claudeAgent) ExtractPrompt(paneContent string) string <span class="cov7" title="8">{ + if c.promptPat == "" </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov7" title="8">re, err := regexp.Compile(c.promptPat) + if err != nil </span><span class="cov0" title="0">{ + return "" + }</span> + // Scope to the last section between ───── delimiters + <span class="cov7" title="8">content := scopeToLastSection(paneContent, c.sectionPat) + // Collect ❯-prefixed lines and their continuation lines (indented + // wrapped text without ❯). Only include non-❯ lines that directly + // follow a ❯-matched line to avoid picking up unrelated content. + paneLines := strings.Split(content, "\n") + var lines []string + inPrompt := false + for _, line := range paneLines </span><span class="cov9" title="14">{ + m := re.FindStringSubmatch(line) + if len(m) >= 2 </span><span class="cov7" title="9">{ + // ❯-prefixed line: use the captured text + cleaned := stripNoise(m[1], c.stripPatterns) + if cleaned != "" </span><span class="cov7" title="8">{ + lines = append(lines, cleaned) + }</span> + <span class="cov7" title="9">inPrompt = true</span> + } else<span class="cov6" title="5"> if inPrompt </span><span class="cov4" title="3">{ + // Non-❯ line after a prompt: include indented continuation text + trimmed := strings.TrimSpace(line) + if trimmed != "" </span><span class="cov1" title="1">{ + lines = append(lines, trimmed) + }</span> else<span class="cov3" title="2"> { + // Empty line breaks the continuation + inPrompt = false + }</span> + } + } + <span class="cov7" title="8">return strings.Join(lines, "\n")</span> +} + +// ClearInput sends vim commands to clear Claude Code's input: +// Escape to ensure normal mode, gg to go to top, C-v G d to visual-block +// select all and delete, then i to re-enter insert mode. +func (c *claudeAgent) ClearInput(paneID string) error <span class="cov3" title="2">{ + if !c.clearFirst || c.clearKeys == "" </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov3" title="2">if err := sendClearSequence(paneID, c.clearKeys); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="2">sleepAfterClear() + return nil</span> +} +</pre> + + <pre class="file" id="file50" style="display: none">package tmuxedit + +import ( + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// configAgent uses baseAgent defaults for all operations. It serves +// user-defined agents from TOML config and simple built-ins (amp, aider) +// that don't need specialized extraction or clearing logic. +type configAgent struct{ baseAgent } + +// builtinAgents returns the default set of agent implementations. Order +// matters: agents with distinctive UI elements (box-drawing, etc.) are +// checked first to avoid false positives from model names like "Claude +// 4.5 Sonnet" appearing in other agents' panes. +func builtinAgents() []Agent <span class="cov10" title="15">{ + return []Agent{ + newCursorAgent(), + newClaudeAgent(), + &configAgent{baseAgent{ + name: "amp", + displayName: "Amp", + detectPattern: `(?i)(amp|sourcegraph)`, + promptPat: `(?m)│\s*(.+?)\s*│\s*$`, + clearFirst: true, + clearKeys: "C-u", + newlineKeys: "S-Enter", + submitKeys: "Enter", + }}, + &configAgent{baseAgent{ + name: "aider", + displayName: "Aider", + detectPattern: `(?i)aider`, + promptPat: `(?m)>\s*(.+)$`, + clearFirst: true, + clearKeys: "C-u", + newlineKeys: "", + submitKeys: "Enter", + }}, + } +}</span> + +// genericAgent returns a fallback agent with no detection or prompt extraction. +// The user gets a blank editor and text is sent verbatim. +func genericAgent() Agent <span class="cov7" title="7">{ + return &configAgent{baseAgent{ + name: "generic", + displayName: "Generic", + newlineKeys: "", + submitKeys: "Enter", + }} +}</span> + +// resolveAgents merges built-in agent defaults with user-provided overrides +// from config. Agents are matched by name (case-insensitive); user config +// wins field-by-field over builtins. The Configurable interface provides +// access to baseAgent fields for merging. +func resolveAgents(cfgAgents []appconfig.TmuxEditAgentCfg) []Agent <span class="cov7" title="8">{ + agents := builtinAgents() + for _, ca := range cfgAgents </span><span class="cov4" title="3">{ + merged := false + for i, a := range agents </span><span class="cov7" title="8">{ + if !strings.EqualFold(a.Name(), ca.Name) </span><span class="cov6" title="6">{ + continue</span> + } + <span class="cov3" title="2">if c, ok := a.(Configurable); ok </span><span class="cov3" title="2">{ + mergeAgentConfig(c.Base(), ca) + }</span> + <span class="cov3" title="2">merged = true + _ = i // index not needed; we modify through the pointer + break</span> + } + <span class="cov4" title="3">if !merged </span><span class="cov1" title="1">{ + agents = append(agents, agentFromConfig(ca)) + }</span> + } + <span class="cov7" title="8">return agents</span> +} + +// mergeAgentConfig overrides fields in base with non-zero values from cfg. +// It modifies the baseAgent in place via pointer. +func mergeAgentConfig(base *baseAgent, cfg appconfig.TmuxEditAgentCfg) <span class="cov3" title="2">{ + if s := strings.TrimSpace(cfg.DisplayName); s != "" </span><span class="cov3" title="2">{ + base.displayName = s + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.DetectPattern); s != "" </span><span class="cov1" title="1">{ + base.detectPattern = s + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.SectionPattern); s != "" </span><span class="cov0" title="0">{ + base.sectionPat = s + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.PromptPattern); s != "" </span><span class="cov1" title="1">{ + base.promptPat = s + }</span> + <span class="cov3" title="2">if len(cfg.StripPatterns) > 0 </span><span class="cov1" title="1">{ + base.stripPatterns = cfg.StripPatterns + }</span> + <span class="cov3" title="2">if cfg.ClearFirst != nil </span><span class="cov3" title="2">{ + base.clearFirst = *cfg.ClearFirst + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.ClearKeys); s != "" </span><span class="cov1" title="1">{ + base.clearKeys = s + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.NewlineKeys); s != "" </span><span class="cov1" title="1">{ + base.newlineKeys = s + }</span> + <span class="cov3" title="2">if s := strings.TrimSpace(cfg.SubmitKeys); s != "" </span><span class="cov1" title="1">{ + base.submitKeys = s + }</span> +} + +// agentFromConfig creates a new configAgent from a user config entry. +func agentFromConfig(cfg appconfig.TmuxEditAgentCfg) Agent <span class="cov3" title="2">{ + b := baseAgent{ + name: strings.TrimSpace(cfg.Name), + displayName: strings.TrimSpace(cfg.DisplayName), + detectPattern: strings.TrimSpace(cfg.DetectPattern), + sectionPat: strings.TrimSpace(cfg.SectionPattern), + promptPat: strings.TrimSpace(cfg.PromptPattern), + stripPatterns: cfg.StripPatterns, + clearKeys: strings.TrimSpace(cfg.ClearKeys), + newlineKeys: strings.TrimSpace(cfg.NewlineKeys), + submitKeys: strings.TrimSpace(cfg.SubmitKeys), + } + if cfg.ClearFirst != nil </span><span class="cov1" title="1">{ + b.clearFirst = *cfg.ClearFirst + }</span> + <span class="cov3" title="2">if b.displayName == "" </span><span class="cov1" title="1">{ + b.displayName = b.name + }</span> + <span class="cov3" title="2">return &configAgent{b}</span> +} +</pre> + + <pre class="file" id="file51" style="display: none">package tmuxedit + +import ( + "regexp" +) + +// cursorAgent handles Cursor's distinctive box-drawing │ → prompt │ UI. +// Cursor uses a text field (not vim), so clearing is done with End + bulk +// backspace. Multi-line prompts are entered with Shift-Enter within the box. +type cursorAgent struct{ baseAgent } + +// newCursorAgent returns a cursorAgent with the default configuration. +// Detect by the box structure or "/ commands" footer. Checked first because +// cursor panes often show model names like "Claude 4.5 Sonnet". +func newCursorAgent() *cursorAgent <span class="cov10" title="18">{ + return &cursorAgent{baseAgent{ + name: "cursor", + displayName: "Cursor", + detectPattern: `(│\s*→|/ commands · @ files)`, + promptPat: `(?m)│\s*→?\s*(.+?)\s*│\s*$`, + stripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"}, + clearFirst: true, + clearKeys: "End BSpace*200", + newlineKeys: "S-Enter", + submitKeys: "Enter", + }} +}</span> + +// ExtractPrompt extracts the prompt text from the last contiguous │...│ block +// in the pane. This avoids picking up earlier command-review or dialog boxes +// that also use box-drawing characters. +func (c *cursorAgent) ExtractPrompt(paneContent string) string <span class="cov7" title="9">{ + if c.promptPat == "" </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov7" title="9">re, err := regexp.Compile(c.promptPat) + if err != nil </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov7" title="9">allMatches := matchPromptLines(re, paneContent) + if len(allMatches) == 0 </span><span class="cov3" title="2">{ + return "" + }</span> + <span class="cov7" title="7">return joinLastContiguousBlock(allMatches, c.stripPatterns)</span> +} + +// ClearInput sends End + 200 backspaces to clear Cursor's text field. +// Cursor's input is a standard text field, not vim. +func (c *cursorAgent) ClearInput(paneID string) error <span class="cov3" title="2">{ + if !c.clearFirst || c.clearKeys == "" </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov3" title="2">if err := sendClearSequence(paneID, c.clearKeys); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="2">sleepAfterClear() + return nil</span> +} +</pre> + + <pre class="file" id="file52" style="display: none">package tmuxedit + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// runCommand is the seam for exec.Command().Output(). Override in tests. +var runCommand = func(name string, args ...string) ([]byte, error) <span class="cov0" title="0">{ + return exec.Command(name, args...).Output() +}</span> + +// resolveTargetPane determines which tmux pane to target using a fallback +// chain: explicit flag > HEXAI_TMUX_PANE env var > tmux query for active pane. +// Returns the pane ID (e.g. "%5") or an error. +func resolveTargetPane(flagPane string) (string, error) <span class="cov10" title="12">{ + // 1. Explicit --pane flag + if p := strings.TrimSpace(flagPane); p != "" </span><span class="cov4" title="3">{ + return p, nil + }</span> + // 2. Environment variable + <span class="cov8" title="9">if p := strings.TrimSpace(os.Getenv("HEXAI_TMUX_PANE")); p != "" </span><span class="cov1" title="1">{ + return p, nil + }</span> + // 3. Query tmux for the active pane in the current window + <span class="cov8" title="8">return queryActivePane()</span> +} + +// queryActivePane asks tmux for the active pane ID using display-message. +func queryActivePane() (string, error) <span class="cov8" title="8">{ + out, err := runCommand("tmux", "display-message", "-p", "#{pane_id}") + if err != nil </span><span class="cov3" title="2">{ + return "", fmt.Errorf("cannot determine tmux pane: %w", err) + }</span> + <span class="cov7" title="6">pane := strings.TrimSpace(string(out)) + if pane == "" </span><span class="cov1" title="1">{ + return "", fmt.Errorf("tmux returned empty pane ID") + }</span> + <span class="cov6" title="5">return pane, nil</span> +} +</pre> + + <pre class="file" id="file53" style="display: none">package tmuxedit + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/tmux" +) + +// Options holds the parsed command-line flags for hexai-tmux-edit. +type Options struct { + ConfigPath string // --config flag + Agent string // --agent flag (explicit agent name, or auto-detect) + Pane string // --pane flag (target pane ID) +} + +// openEditorPopup is the seam for opening an editor in a tmux popup. +// It creates a temp file, opens it in a tmux popup with the user's editor, +// waits for completion, and returns the edited content. Override in tests. +var openEditorPopup = func(initial, popupW, popupH string) (string, error) <span class="cov0" title="0">{ + ed, err := editor.Resolve() + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + // Create a temp file with the initial content + <span class="cov0" title="0">f, err := os.CreateTemp("", "hexai-tmux-edit-*.md") + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("create temp file: %w", err) + }</span> + <span class="cov0" title="0">path := f.Name() + defer func() </span><span class="cov0" title="0">{ _ = os.Remove(path) }</span>() + + <span class="cov0" title="0">if initial != "" </span><span class="cov0" title="0">{ + if _, err := f.WriteString(initial); err != nil </span><span class="cov0" title="0">{ + _ = f.Close() + return "", fmt.Errorf("write initial content: %w", err) + }</span> + } + <span class="cov0" title="0">if err := f.Close(); err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("close temp file: %w", err) + }</span> + + // Build the tmux display-popup command to launch the editor + <span class="cov0" title="0">if err := launchPopup(ed, path, popupW, popupH); err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("popup editor: %w", err) + }</span> + + <span class="cov0" title="0">b, err := os.ReadFile(path) + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("read edited file: %w", err) + }</span> + <span class="cov0" title="0">return strings.TrimSpace(string(b)), nil</span> +} + +// launchPopup is the seam for running `tmux display-popup` with the editor. +// The -E flag makes the popup close when the editor exits. Uses .Run() +// (not .Output()) so the popup blocks until the user closes the editor. +var launchPopup = func(ed, path, width, height string) error <span class="cov0" title="0">{ + args := []string{"display-popup", "-E"} + if width != "" </span><span class="cov0" title="0">{ + args = append(args, "-w", width) + }</span> + <span class="cov0" title="0">if height != "" </span><span class="cov0" title="0">{ + args = append(args, "-h", height) + }</span> + <span class="cov0" title="0">args = append(args, ed+" "+shellQuote(path)) + return exec.Command("tmux", args...).Run()</span> +} + +// shellQuote wraps a path in single quotes for safe shell use. +func shellQuote(s string) string <span class="cov3" title="3">{ + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +}</span> + +// Run is the main orchestrator for hexai-tmux-edit. It: +// 1. Checks tmux availability +// 2. Resolves the target pane +// 3. Captures pane content +// 4. Detects or selects the agent +// 5. Extracts the current prompt +// 6. Opens the editor in a popup +// 7. Deduplicates and sends edited text back +func Run(opts Options) error <span class="cov0" title="0">{ + if !tmux.Available() </span><span class="cov0" title="0">{ + return fmt.Errorf("tmux is not available (not in a tmux session)") + }</span> + <span class="cov0" title="0">cfg := loadConfig(opts.ConfigPath) + return runWithConfig(opts, cfg)</span> +} + +// loadConfig loads the application config, extracting tmux_edit settings. +func loadConfig(configPath string) appconfig.App <span class="cov0" title="0">{ + logger := log.New(os.Stderr, "[hexai-tmux-edit] ", log.LstdFlags) + lopts := appconfig.LoadOptions{ConfigPath: configPath} + return appconfig.LoadWithOptions(logger, lopts) +}</span> + +// debugLog is the debug logger. Set to a real logger via initDebugLog(). +var debugLog *log.Logger + +// initDebugLog creates a debug log file at /tmp/hexai-tmux-edit.log. +func initDebugLog() <span class="cov5" title="7">{ + f, err := os.OpenFile("/tmp/hexai-tmux-edit.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov5" title="7">debugLog = log.New(f, "", log.LstdFlags|log.Lmicroseconds)</span> +} + +func dbg(format string, args ...any) <span class="cov10" title="58">{ + if debugLog != nil </span><span class="cov10" title="58">{ + debugLog.Printf(format, args...) + }</span> +} + +// runWithConfig executes the edit workflow using the provided config. +// It resolves the agent (by name or auto-detect), extracts the current +// prompt, opens the editor popup, then clears and sends the result. +func runWithConfig(opts Options, cfg appconfig.App) error <span class="cov5" title="7">{ + initDebugLog() + dbg("=== hexai-tmux-edit start ===") + dbg("opts: pane=%q agent=%q config=%q", opts.Pane, opts.Agent, opts.ConfigPath) + + paneID, err := resolveTargetPane(opts.Pane) + if err != nil </span><span class="cov1" title="1">{ + dbg("resolveTargetPane error: %v", err) + return err + }</span> + <span class="cov4" title="6">dbg("resolved pane: %q", paneID) + + content, err := capturePane(paneID) + if err != nil </span><span class="cov1" title="1">{ + dbg("capturePane error: %v", err) + return err + }</span> + <span class="cov4" title="5">dbg("captured %d bytes from pane", len(content)) + logPaneLines(content) + + agents := resolveAgents(cfg.TmuxEditAgents) + agent := pickAgent(opts.Agent, content, agents) + dbg("agent: name=%q", agent.Name()) + + original := agent.ExtractPrompt(content) + dbg("extractPrompt result: %q", original) + + popupW, popupH := popupDimensions(cfg) + dbg("opening editor popup: w=%s h=%s initial=%q", popupW, popupH, original) + + edited, err := openEditorPopup(original, popupW, popupH) + if err != nil </span><span class="cov1" title="1">{ + dbg("openEditorPopup error: %v", err) + return err + }</span> + <span class="cov4" title="4">dbg("editor returned: %q", edited) + + text := deduplicateText(original, edited) + dbg("deduplicateText result: %q", text) + if text == "" </span><span class="cov1" title="1">{ + dbg("nothing to send, exiting") + return nil + }</span> + + <span class="cov3" title="3">dbg("clearing and sending to pane %q: %q", paneID, text) + if err := agent.ClearInput(paneID); err != nil </span><span class="cov0" title="0">{ + dbg("ClearInput error: %v", err) + return err + }</span> + <span class="cov3" title="3">if err := agent.SendText(paneID, text); err != nil </span><span class="cov0" title="0">{ + dbg("SendText error: %v", err) + return err + }</span> + <span class="cov3" title="3">dbg("=== done ===") + return nil</span> +} + +// logPaneLines logs lines containing box-drawing or arrow characters for +// debugging prompt detection. +func logPaneLines(content string) <span class="cov4" title="5">{ + for i, line := range strings.Split(content, "\n") </span><span class="cov6" title="10">{ + if strings.Contains(line, "│") || strings.Contains(line, "→") </span><span class="cov0" title="0">{ + dbg(" pane line %d: %q", i, line) + }</span> + } +} + +// popupDimensions returns the popup width and height from config, defaulting +// to "80%" for both if not set. +func popupDimensions(cfg appconfig.App) (string, string) <span class="cov4" title="5">{ + w := cfg.TmuxEditPopupWidth + if w == "" </span><span class="cov4" title="4">{ + w = "80%" + }</span> + <span class="cov4" title="5">h := cfg.TmuxEditPopupHeight + if h == "" </span><span class="cov4" title="4">{ + h = "80%" + }</span> + <span class="cov4" title="5">return w, h</span> +} + +// pickAgent selects an agent by explicit name or auto-detection. +func pickAgent(name, content string, agents []Agent) Agent <span class="cov5" title="7">{ + if name != "" </span><span class="cov2" title="2">{ + return findAgentByName(name, agents) + }</span> + <span class="cov4" title="5">return detectAgent(content, agents)</span> +} +</pre> + + <pre class="file" id="file54" style="display: none">package tmuxedit + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// sendKeys is the seam for `tmux send-keys`. Override in tests. +var sendKeys = func(paneID string, keys ...string) error <span class="cov0" title="0">{ + args := append([]string{"send-keys", "-t", paneID}, keys...) + _, err := runCommand("tmux", args...) + if err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("send-keys failed: %w", err) + }</span> + <span class="cov0" title="0">return nil</span> +} + +// sendRepeatedKey is the seam for `tmux send-keys -N <count>`. Override in +// tests. Uses -N for efficient bulk key repeats (e.g. 200 backspaces). +var sendRepeatedKey = func(paneID, key string, count int) error <span class="cov1" title="1">{ + args := []string{"send-keys", "-t", paneID, "-N", strconv.Itoa(count), key} + _, err := runCommand("tmux", args...) + if err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("send-keys -N failed: %w", err) + }</span> + <span class="cov1" title="1">return nil</span> +} + +// sleepAfterClear pauses to let the TUI drain queued keystrokes (like bulk +// backspaces) before new text is sent. Override in tests to avoid delays. +var sleepAfterClear = func() <span class="cov0" title="0">{ time.Sleep(300 * time.Millisecond) }</span> + +// deduplicateText compares the original (pre-filled) text with what the user +// returned from the editor. Returns empty string if unchanged (no-op), or +// the full edited text if anything changed. The caller is responsible for +// clearing existing pane input before sending the result, so we always return +// the complete text rather than stripping the original prefix. +func deduplicateText(original, edited string) string <span class="cov10" title="12">{ + original = strings.TrimSpace(original) + edited = strings.TrimSpace(edited) + if edited == "" || edited == original </span><span class="cov6" title="4">{ + return "" + }</span> + <span class="cov8" title="8">return edited</span> +} +</pre> + + </div> + </body> + <script> + (function() { + var files = document.getElementById('files'); + var visible; + files.addEventListener('change', onChange, false); + function select(part) { + if (visible) + visible.style.display = 'none'; + visible = document.getElementById(part); + if (!visible) + return; + files.value = part; + visible.style.display = 'block'; + location.hash = part; + } + function onChange() { + select(files.value); + window.scrollTo(0, 0); + } + if (location.hash != "") { + select(location.hash.substr(1)); + } + if (!visible) { + select("file0"); + } + })(); + </script> +</html> |
