summaryrefslogtreecommitdiff
path: root/docs/coverage.html
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
committerPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
commitf4470bbcfbe3b14c99baeef475fe872825a13a39 (patch)
treee12fc6168d21119dfff3a0fef5b6c5b54149f3ab /docs/coverage.html
parent68438c98d23545ff791768e3e219cd21d3814e0c (diff)
release: v0.10.0v0.10.0
Diffstat (limited to 'docs/coverage.html')
-rw-r--r--docs/coverage.html2904
1 files changed, 1791 insertions, 1113 deletions
diff --git a/docs/coverage.html b/docs/coverage.html
index 059834b..2d72d59 100644
--- a/docs/coverage.html
+++ b/docs/coverage.html
@@ -61,7 +61,7 @@
<option value="file2">codeberg.org/snonux/hexai/cmd/hexai/main.go (71.4%)</option>
- <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (90.6%)</option>
+ <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (90.8%)</option>
<option value="file4">codeberg.org/snonux/hexai/internal/editor/editor.go (58.3%)</option>
@@ -69,63 +69,67 @@
<option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/parse.go (92.6%)</option>
- <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (85.0%)</option>
+ <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (87.5%)</option>
- <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (69.2%)</option>
+ <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (67.2%)</option>
<option value="file9">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option>
- <option value="file10">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (100.0%)</option>
+ <option value="file10">codeberg.org/snonux/hexai/internal/hexaiaction/tui_custom.go (83.8%)</option>
- <option value="file11">codeberg.org/snonux/hexai/internal/hexaicli/run.go (88.7%)</option>
+ <option value="file11">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (100.0%)</option>
- <option value="file12">codeberg.org/snonux/hexai/internal/hexailsp/run.go (92.5%)</option>
+ <option value="file12">codeberg.org/snonux/hexai/internal/hexaicli/run.go (88.6%)</option>
- <option value="file13">codeberg.org/snonux/hexai/internal/llm/copilot.go (82.4%)</option>
+ <option value="file13">codeberg.org/snonux/hexai/internal/hexailsp/run.go (83.7%)</option>
- <option value="file14">codeberg.org/snonux/hexai/internal/llm/ollama.go (89.8%)</option>
+ <option value="file14">codeberg.org/snonux/hexai/internal/llm/copilot.go (82.4%)</option>
- <option value="file15">codeberg.org/snonux/hexai/internal/llm/openai.go (85.5%)</option>
+ <option value="file15">codeberg.org/snonux/hexai/internal/llm/ollama.go (89.8%)</option>
- <option value="file16">codeberg.org/snonux/hexai/internal/llm/provider.go (100.0%)</option>
+ <option value="file16">codeberg.org/snonux/hexai/internal/llm/openai.go (85.5%)</option>
- <option value="file17">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option>
+ <option value="file17">codeberg.org/snonux/hexai/internal/llm/provider.go (100.0%)</option>
- <option value="file18">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option>
+ <option value="file18">codeberg.org/snonux/hexai/internal/llm/util.go (100.0%)</option>
- <option value="file19">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option>
+ <option value="file19">codeberg.org/snonux/hexai/internal/llmutils/client.go (100.0%)</option>
- <option value="file20">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option>
+ <option value="file20">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (100.0%)</option>
- <option value="file21">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option>
+ <option value="file21">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option>
- <option value="file22">codeberg.org/snonux/hexai/internal/lsp/document.go (90.1%)</option>
+ <option value="file22">codeberg.org/snonux/hexai/internal/lsp/context.go (74.4%)</option>
- <option value="file23">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.9%)</option>
+ <option value="file23">codeberg.org/snonux/hexai/internal/lsp/document.go (90.1%)</option>
- <option value="file24">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (78.8%)</option>
+ <option value="file24">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.9%)</option>
- <option value="file25">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (87.6%)</option>
+ <option value="file25">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (82.3%)</option>
- <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (88.9%)</option>
+ <option value="file26">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (87.9%)</option>
- <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option>
+ <option value="file27">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (88.9%)</option>
- <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (66.7%)</option>
+ <option value="file28">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option>
- <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (89.1%)</option>
+ <option value="file29">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (63.6%)</option>
- <option value="file30">codeberg.org/snonux/hexai/internal/lsp/server.go (82.6%)</option>
+ <option value="file30">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (89.4%)</option>
- <option value="file31">codeberg.org/snonux/hexai/internal/lsp/transport.go (71.4%)</option>
+ <option value="file31">codeberg.org/snonux/hexai/internal/lsp/server.go (81.8%)</option>
- <option value="file32">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option>
+ <option value="file32">codeberg.org/snonux/hexai/internal/lsp/transport.go (71.4%)</option>
- <option value="file33">codeberg.org/snonux/hexai/internal/textutil/textutil.go (89.0%)</option>
+ <option value="file33">codeberg.org/snonux/hexai/internal/testutil/fixtures.go (100.0%)</option>
- <option value="file34">codeberg.org/snonux/hexai/internal/tmux/status.go (66.7%)</option>
+ <option value="file34">codeberg.org/snonux/hexai/internal/textutil/human.go (92.3%)</option>
- <option value="file35">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option>
+ <option value="file35">codeberg.org/snonux/hexai/internal/textutil/textutil.go (90.4%)</option>
+
+ <option value="file36">codeberg.org/snonux/hexai/internal/tmux/status.go (68.5%)</option>
+
+ <option value="file37">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option>
</select>
</div>
@@ -178,33 +182,32 @@ func main() <span class="cov8" title="1">{
<pre class="file" id="file1" style="display: none">package main
import (
- "context"
- "flag"
- "fmt"
- "os"
+ "context"
+ "flag"
+ "fmt"
+ "os"
- "codeberg.org/snonux/hexai/internal/hexaiaction"
+ "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")
- 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,
- }
- if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
- }</span>
-}
+ 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")
+ 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,
+ }
+ if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }</span>
+}
</pre>
<pre class="file" id="file2" style="display: none">// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli.
@@ -310,21 +313,37 @@ type App struct {
// 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:"-"`
+ 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:"-"`
+}
+
+// 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
}
// Constructor: defaults for App (kept first among functions)
-func newDefaultConfig() App <span class="cov5" title="19">{
+func newDefaultConfig() App <span class="cov5" title="30">{
// Coding-friendly default temperature across providers
// Users can override per provider in config.toml (including 0.0).
t := 0.2
@@ -365,10 +384,10 @@ func newDefaultConfig() App <span class="cov5" title="19">{
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}}",
+ 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.",
@@ -377,18 +396,18 @@ func newDefaultConfig() App <span class="cov5" title="19">{
// 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="18">{
+func Load(logger *log.Logger) App <span class="cov5" title="29">{
cfg := newDefaultConfig()
- if logger == nil </span><span class="cov3" title="4">{
+ if logger == nil </span><span class="cov4" title="8">{
return cfg // Return defaults if no logger is provided (e.g. in tests)
}</span>
- <span class="cov5" title="14">configPath, err := getConfigPath()
+ <span class="cov5" title="21">configPath, err := getConfigPath()
if err != nil </span><span class="cov0" title="0">{
logger.Printf("%v", err)
// Even if config path cannot be resolved, still allow env overrides below.
- }</span> else<span class="cov5" title="14"> {
- if fileCfg, err := loadFromFile(configPath, logger); err == nil &amp;&amp; fileCfg != nil </span><span class="cov3" title="4">{
+ }</span> else<span class="cov5" title="21"> {
+ if fileCfg, err := loadFromFile(configPath, logger); err == nil &amp;&amp; fileCfg != nil </span><span class="cov4" title="11">{
cfg.mergeWith(fileCfg)
}</span>
// When the config file is missing or invalid, we keep defaults and still
@@ -396,10 +415,10 @@ func Load(logger *log.Logger) App <span class="cov5" title="18">{
}
// Environment overrides (take precedence over file)
- <span class="cov5" title="14">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{
cfg.mergeWith(envCfg)
}</span>
- <span class="cov5" title="14">return cfg</span>
+ <span class="cov5" title="21">return cfg</span>
}
// Private helpers
@@ -417,6 +436,7 @@ type fileConfig struct {
Copilot sectionCopilot `toml:"copilot"`
Ollama sectionOllama `toml:"ollama"`
Prompts sectionPrompts `toml:"prompts"`
+ Tmux sectionTmux `toml:"tmux"`
}
type sectionGeneral struct {
@@ -496,16 +516,17 @@ type sectionPromptsChat struct {
}
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"`
+ 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 {
@@ -517,7 +538,22 @@ type sectionPromptsProviderNative struct {
Completion string `toml:"completion"`
}
-func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
+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="cov4" title="11">{
out := App{}
// Merge section: general
@@ -533,13 +569,13 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
// logging
- <span class="cov3" title="4">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">if (fc.Logging != sectionLogging{}) </span><span class="cov1" title="1">{
tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit}
out.mergeBasics(&amp;tmp)
}</span>
// completion
- <span class="cov3" title="4">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if (fc.Completion != sectionCompletion{}) </span><span class="cov2" title="3">{
tmp := App{
CompletionDebounceMs: fc.Completion.CompletionDebounceMs,
CompletionThrottleMs: fc.Completion.CompletionThrottleMs,
@@ -549,31 +585,31 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
// triggers
- <span class="cov3" title="4">if len(fc.Triggers.TriggerCharacters) &gt; 0 </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if len(fc.Triggers.TriggerCharacters) &gt; 0 </span><span class="cov2" title="3">{
tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters}
out.mergeBasics(&amp;tmp)
}</span>
// inline
- <span class="cov3" title="4">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">if (fc.Inline != sectionInline{}) </span><span class="cov1" title="1">{
tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose}
out.mergeBasics(&amp;tmp)
}</span>
// chat
- <span class="cov3" title="4">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) &gt; 0 </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) &gt; 0 </span><span class="cov1" title="1">{
tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes}
out.mergeBasics(&amp;tmp)
}</span>
// provider
- <span class="cov3" title="4">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if strings.TrimSpace(fc.Provider.Name) != "" </span><span class="cov2" title="3">{
tmp := App{Provider: fc.Provider.Name}
out.mergeBasics(&amp;tmp)
}</span>
// openai
- <span class="cov3" title="4">if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if (fc.OpenAI != sectionOpenAI{}) || fc.OpenAI.Temperature != nil </span><span class="cov2" title="3">{
tmp := App{
OpenAIBaseURL: fc.OpenAI.BaseURL,
OpenAIModel: fc.OpenAI.Model,
@@ -583,7 +619,7 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
// copilot
- <span class="cov3" title="4">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil </span><span class="cov2" title="3">{
tmp := App{
CopilotBaseURL: fc.Copilot.BaseURL,
CopilotModel: fc.Copilot.Model,
@@ -593,7 +629,7 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
// ollama
- <span class="cov3" title="4">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil </span><span class="cov2" title="3">{
tmp := App{
OllamaBaseURL: fc.Ollama.BaseURL,
OllamaModel: fc.Ollama.Model,
@@ -604,7 +640,7 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
// prompts
// completion
- <span class="cov3" title="4">if (fc.Prompts.Completion != sectionPromptsCompletion{}) </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">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>
@@ -625,44 +661,68 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
}
// chat
- <span class="cov3" title="4">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">if strings.TrimSpace(fc.Prompts.Chat.System) != "" </span><span class="cov1" title="1">{
out.PromptChatSystem = fc.Prompts.Chat.System
}</span>
// code action
- <span class="cov3" title="4">if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">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) &gt; 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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" </span><span class="cov1" title="1">{
+ <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="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" </span><span class="cov1" title="1">{
- out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser
- }</span>
- <span class="cov1" title="1">if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" </span><span class="cov0" title="0">{
- out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem
- }</span>
- <span class="cov1" title="1">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 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) &gt; 0 </span><span class="cov3" title="6">{
+ for _, ca := range fc.Prompts.CodeAction.Custom </span><span class="cov4" 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="cov3" title="4">if (fc.Prompts.CLI != sectionPromptsCLI{}) </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">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>
@@ -671,14 +731,19 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{
}</span>
}
// provider-native
- <span class="cov3" title="4">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="11">if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" </span><span class="cov1" title="1">{
out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion
}</span>
- <span class="cov3" title="4">return out</span>
+ // tmux
+ <span class="cov4" title="11">if (fc.Tmux != sectionTmux{}) </span><span class="cov2" title="3">{
+ out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey)
+ }</span>
+
+ <span class="cov4" title="11">return out</span>
}
-func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="15">{
+func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov5" title="22">{
b, err := os.ReadFile(path)
if err != nil </span><span class="cov4" title="9">{
if !os.IsNotExist(err) &amp;&amp; logger != nil </span><span class="cov0" title="0">{
@@ -687,7 +752,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co
<span class="cov4" title="9">return nil, err</span>
}
- <span class="cov3" title="6">var tables fileConfig
+ <span class="cov4" title="13">var tables fileConfig
errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&amp;tables)
// Raw map for validation/presence checks
var raw map[string]any
@@ -700,7 +765,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co
}
// Reject legacy flat keys at top-level (sectioned-only config is allowed)
- <span class="cov3" title="4">legacy := map[string]struct{}{
+ <span class="cov4" title="11">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": {},
@@ -709,22 +774,22 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co
"ollama_model": {}, "ollama_base_url": {}, "ollama_temperature": {},
"copilot_model": {}, "copilot_base_url": {}, "copilot_temperature": {},
}
- for k := range raw </span><span class="cov6" title="27">{
- if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="27">{
+ for k := range raw </span><span class="cov6" title="36">{
+ if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable </span><span class="cov6" title="33">{
continue</span>
}
- <span class="cov0" title="0">if _, isLegacy := legacy[k]; isLegacy </span><span class="cov0" title="0">{
+ <span class="cov2" title="3">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="cov3" title="4">if logger != nil </span><span class="cov3" title="4">{
+ <span class="cov4" title="11">if logger != nil </span><span class="cov4" title="11">{
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="cov3" title="4">tab := tables.toApp()
+ <span class="cov4" title="11">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="3">{
if v, present := t["manual_invoke_min_prefix"]; present </span><span class="cov2" title="3">{
@@ -738,7 +803,7 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co
}
}
}
- <span class="cov3" title="4">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="3">{
+ <span class="cov4" title="11">if t, ok := raw["logging"].(map[string]any); ok </span><span class="cov2" title="3">{
if v, present := t["log_preview_limit"]; present </span><span class="cov2" title="3">{
switch vv := v.(type) </span>{
case int64:<span class="cov2" title="3">
@@ -750,167 +815,231 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co
}
}
}
- <span class="cov3" title="4">return &amp;tab, nil</span>
+ <span class="cov4" title="11">return &amp;tab, nil</span>
}
-func (a *App) mergeWith(other *App) <span class="cov3" title="5">{
+func (a *App) mergeWith(other *App) <span class="cov4" title="12">{
a.mergeBasics(other)
a.mergeProviderFields(other)
a.mergePrompts(other)
}</span>
// mergeBasics merges general (non-provider) fields.
-func (a *App) mergeBasics(other *App) <span class="cov5" title="20">{
- if other.MaxTokens &gt; 0 </span><span class="cov4" title="7">{
+func (a *App) mergeBasics(other *App) <span class="cov5" title="27">{
+ if other.MaxTokens &gt; 0 </span><span class="cov3" title="7">{
a.MaxTokens = other.MaxTokens
}</span>
- <span class="cov5" title="20">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov3" title="7">{
a.ContextMode = s
}</span>
- <span class="cov5" title="20">if other.ContextWindowLines &gt; 0 </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if other.ContextWindowLines &gt; 0 </span><span class="cov3" title="7">{
a.ContextWindowLines = other.ContextWindowLines
}</span>
- <span class="cov5" title="20">if other.MaxContextTokens &gt; 0 </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if other.MaxContextTokens &gt; 0 </span><span class="cov3" title="7">{
a.MaxContextTokens = other.MaxContextTokens
}</span>
- <span class="cov5" title="20">if other.LogPreviewLimit &gt;= 0 </span><span class="cov5" title="20">{
+ <span class="cov5" title="27">if other.LogPreviewLimit &gt;= 0 </span><span class="cov5" title="27">{
a.LogPreviewLimit = other.LogPreviewLimit
}</span>
- <span class="cov5" title="20">if other.CodingTemperature != nil </span><span class="cov4" title="7">{ // allow explicit 0.0
+ <span class="cov5" title="27">if other.CodingTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0
a.CodingTemperature = other.CodingTemperature
}</span>
- <span class="cov5" title="20">if other.ManualInvokeMinPrefix &gt;= 0 </span><span class="cov5" title="20">{
+ <span class="cov5" title="27">if other.ManualInvokeMinPrefix &gt;= 0 </span><span class="cov5" title="27">{
a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix
}</span>
- <span class="cov5" title="20">if other.CompletionDebounceMs &gt; 0 </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if other.CompletionDebounceMs &gt; 0 </span><span class="cov3" title="7">{
a.CompletionDebounceMs = other.CompletionDebounceMs
}</span>
- <span class="cov5" title="20">if other.CompletionThrottleMs &gt; 0 </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if other.CompletionThrottleMs &gt; 0 </span><span class="cov3" title="7">{
a.CompletionThrottleMs = other.CompletionThrottleMs
}</span>
- <span class="cov5" title="20">if len(other.TriggerCharacters) &gt; 0 </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if len(other.TriggerCharacters) &gt; 0 </span><span class="cov3" title="7">{
a.TriggerCharacters = slices.Clone(other.TriggerCharacters)
}</span>
- <span class="cov5" title="20">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov2" title="2">{
+ <span class="cov5" title="27">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov2" title="2">{
a.InlineOpen = s
}</span>
- <span class="cov5" title="20">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov2" title="2">{
+ <span class="cov5" title="27">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov2" title="2">{
a.InlineClose = s
}</span>
- <span class="cov5" title="20">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov2" title="2">{
+ <span class="cov5" title="27">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov2" title="2">{
a.ChatSuffix = s
}</span>
- <span class="cov5" title="20">if len(other.ChatPrefixes) &gt; 0 </span><span class="cov2" title="2">{
+ <span class="cov5" title="27">if len(other.ChatPrefixes) &gt; 0 </span><span class="cov2" title="2">{
a.ChatPrefixes = slices.Clone(other.ChatPrefixes)
}</span>
- <span class="cov5" title="20">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="27">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov3" title="7">{
a.Provider = s
}</span>
}
// mergePrompts copies non-empty prompt templates from other.
-func (a *App) mergePrompts(other *App) <span class="cov3" title="5">{
+func (a *App) mergePrompts(other *App) <span class="cov4" title="12">{
// Completion
if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" </span><span class="cov1" title="1">{
a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCompletionSystemParams) != "" </span><span class="cov1" title="1">{
a.PromptCompletionSystemParams = other.PromptCompletionSystemParams
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCompletionSystemInline) != "" </span><span class="cov1" title="1">{
a.PromptCompletionSystemInline = other.PromptCompletionSystemInline
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" </span><span class="cov1" title="1">{
a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCompletionUserParams) != "" </span><span class="cov1" title="1">{
a.PromptCompletionUserParams = other.PromptCompletionUserParams
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" </span><span class="cov1" title="1">{
a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader
}</span>
// Provider-native
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptNativeCompletion) != "" </span><span class="cov1" title="1">{
a.PromptNativeCompletion = other.PromptNativeCompletion
}</span>
// Chat
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptChatSystem) != "" </span><span class="cov1" title="1">{
a.PromptChatSystem = other.PromptChatSystem
}</span>
// Code actions
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" </span><span class="cov1" title="1">{
a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{
- a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser
- }</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{
- a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem
- }</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{
- a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser
- }</span>
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" </span><span class="cov1" title="1">{
+ a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser
+ }</span>
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" </span><span class="cov0" title="0">{
+ a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem
+ }</span>
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" </span><span class="cov0" title="0">{
+ a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser
+ }</span>
// CLI
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" </span><span class="cov1" title="1">{
a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem
}</span>
- <span class="cov3" title="5">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{
+ <span class="cov4" title="12">if strings.TrimSpace(other.PromptCLIExplainSystem) != "" </span><span class="cov1" title="1">{
a.PromptCLIExplainSystem = other.PromptCLIExplainSystem
}</span>
+ // Custom actions
+ <span class="cov4" title="12">if len(other.CustomActions) &gt; 0 </span><span class="cov3" title="6">{
+ a.CustomActions = append([]CustomAction{}, other.CustomActions...)
+ }</span>
+ <span class="cov4" title="12">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.
+func (a App) Validate() error <span class="cov5" title="19">{
+ // 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="cov4" 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="cov4" 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 != "" &amp;&amp; scope != "selection" &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; !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="cov3" 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="cov3" 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="14">if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" </span><span class="cov2" 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="cov2" 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="13">return nil</span>
}
// mergeProviderFields merges per-provider configuration.
-func (a *App) mergeProviderFields(other *App) <span class="cov5" title="14">{
- if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov4" title="7">{
+func (a *App) mergeProviderFields(other *App) <span class="cov5" title="21">{
+ if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov3" title="7">{
a.OpenAIBaseURL = s
}</span>
- <span class="cov5" title="14">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="21">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov3" title="7">{
a.OpenAIModel = s
}</span>
- <span class="cov5" title="14">if other.OpenAITemperature != nil </span><span class="cov4" title="7">{ // allow explicit 0.0
+ <span class="cov5" title="21">if other.OpenAITemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0
a.OpenAITemperature = other.OpenAITemperature
}</span>
- <span class="cov5" title="14">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="21">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov3" title="7">{
a.OllamaBaseURL = s
}</span>
- <span class="cov5" title="14">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="21">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov3" title="7">{
a.OllamaModel = s
}</span>
- <span class="cov5" title="14">if other.OllamaTemperature != nil </span><span class="cov4" title="7">{ // allow explicit 0.0
+ <span class="cov5" title="21">if other.OllamaTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0
a.OllamaTemperature = other.OllamaTemperature
}</span>
- <span class="cov5" title="14">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="21">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov3" title="7">{
a.CopilotBaseURL = s
}</span>
- <span class="cov5" title="14">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov4" title="7">{
+ <span class="cov5" title="21">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov3" title="7">{
a.CopilotModel = s
}</span>
- <span class="cov5" title="14">if other.CopilotTemperature != nil </span><span class="cov4" title="7">{ // allow explicit 0.0
+ <span class="cov5" title="21">if other.CopilotTemperature != nil </span><span class="cov3" title="7">{ // allow explicit 0.0
a.CopilotTemperature = other.CopilotTemperature
}</span>
}
-func getConfigPath() (string, error) <span class="cov5" title="15">{
+func getConfigPath() (string, error) <span class="cov5" title="22">{
var configPath string
- if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov4" title="7">{
+ if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov4" title="14">{
configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml")
}</span> else<span class="cov4" title="8"> {
home, err := os.UserHomeDir()
@@ -919,36 +1048,36 @@ func getConfigPath() (string, error) <span class="cov5" title="15">{
}</span>
<span class="cov4" title="8">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span>
}
- <span class="cov5" title="15">return configPath, nil</span>
+ <span class="cov5" title="22">return configPath, nil</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="14">{
+func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="21">{
var out App
var any bool
// helpers
- getenv := func(k string) string </span><span class="cov10" title="336">{ return strings.TrimSpace(os.Getenv(k)) }</span>
- <span class="cov5" title="14">parseInt := func(k string) (int, bool) </span><span class="cov8" title="98">{
+ getenv := func(k string) string </span><span class="cov10" title="504">{ return strings.TrimSpace(os.Getenv(k)) }</span>
+ <span class="cov5" title="21">parseInt := func(k string) (int, bool) </span><span class="cov8" title="147">{
v := getenv(k)
- if v == "" </span><span class="cov7" title="91">{
+ if v == "" </span><span class="cov8" title="140">{
return 0, false
}</span>
- <span class="cov4" title="7">n, err := strconv.Atoi(v)
+ <span class="cov3" title="7">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="cov4" title="7">return n, true</span>
+ <span class="cov3" title="7">return n, true</span>
}
- <span class="cov5" title="14">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="56">{
+ <span class="cov5" title="21">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="84">{
v := getenv(k)
- if v == "" </span><span class="cov7" title="52">{
+ if v == "" </span><span class="cov7" title="80">{
return nil, false
}</span>
<span class="cov3" title="4">f, err := strconv.ParseFloat(v, 64)
@@ -961,43 +1090,43 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="14">{
<span class="cov3" title="4">return &amp;f, true</span>
}
- <span class="cov5" title="14">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{
out.MaxTokens = n
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{
out.ContextMode = s
any = true
}</span>
- <span class="cov5" title="14">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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="14">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{
out.CodingTemperature = f
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">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">{
@@ -1007,19 +1136,19 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="14">{
}
<span class="cov1" title="1">any = true</span>
}
- <span class="cov5" title="14">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{
out.InlineOpen = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{
out.InlineClose = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{
out.ChatSuffix = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{
+ <span class="cov5" title="21">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">{
@@ -1029,52 +1158,52 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="14">{
}
<span class="cov0" title="0">any = true</span>
}
- <span class="cov5" title="14">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{
out.Provider = s
any = true
}</span>
// Provider-specific
- <span class="cov5" title="14">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{
out.OpenAIBaseURL = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{
out.OpenAIModel = s
any = true
}</span>
- <span class="cov5" title="14">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{
out.OpenAITemperature = f
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{
out.OllamaBaseURL = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{
out.OllamaModel = s
any = true
}</span>
- <span class="cov5" title="14">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{
out.OllamaTemperature = f
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{
out.CopilotBaseURL = s
any = true
}</span>
- <span class="cov5" title="14">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{
out.CopilotModel = s
any = true
}</span>
- <span class="cov5" title="14">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{
+ <span class="cov5" title="21">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{
out.CopilotTemperature = f
any = true
}</span>
- <span class="cov5" title="14">if !any </span><span class="cov4" title="13">{
+ <span class="cov5" title="21">if !any </span><span class="cov5" title="20">{
return nil
}</span>
<span class="cov1" title="1">return &amp;out</span>
@@ -1084,234 +1213,268 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov5" title="14">{
<pre class="file" id="file4" style="display: none">package editor
import (
- "errors"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
+ "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="5">{
- ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR"))
- if ed == "" </span><span class="cov1" title="1">{
- ed = strings.TrimSpace(os.Getenv("EDITOR"))
- }</span>
- <span class="cov10" title="5">if ed == "" </span><span class="cov0" title="0">{
- return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)")
- }</span>
- <span class="cov10" title="5">return ed, nil</span>
+ ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR"))
+ if ed == "" </span><span class="cov1" title="1">{
+ ed = strings.TrimSpace(os.Getenv("EDITOR"))
+ }</span>
+ <span class="cov10" title="5">if ed == "" </span><span class="cov0" title="0">{
+ return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)")
+ }</span>
+ <span class="cov10" title="5">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()
+ 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="3">{
- 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="3">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="3">path := f.Name()
- defer func() </span><span class="cov7" title="3">{ _ = os.Remove(path) }</span>()
- <span class="cov7" title="3">if len(initial) &gt; 0 </span><span class="cov1" title="1">{
- if _, err := f.Write(initial); err != nil </span><span class="cov0" title="0">{
- _ = f.Close()
- return "", err
- }</span>
- }
- <span class="cov7" title="3">if err := f.Sync(); err != nil </span><span class="cov0" title="0">{
- _ = f.Close()
- return "", err
- }</span>
- <span class="cov7" title="3">if err := f.Close(); err != nil </span><span class="cov0" title="0">{
- return "", err
- }</span>
- <span class="cov7" title="3">if err := RunEditor(ed, path); err != nil </span><span class="cov0" title="0">{
- return "", err
- }</span>
- <span class="cov7" title="3">b, err := os.ReadFile(filepath.Clean(path))
- if err != nil </span><span class="cov0" title="0">{
- return "", err
- }</span>
- <span class="cov7" title="3">return strings.TrimSpace(string(b)), nil</span>
+ 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="3">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="3">path := f.Name()
+ defer func() </span><span class="cov7" title="3">{ _ = os.Remove(path) }</span>()
+ <span class="cov7" title="3">if len(initial) &gt; 0 </span><span class="cov1" title="1">{
+ if _, err := f.Write(initial); err != nil </span><span class="cov0" title="0">{
+ _ = f.Close()
+ return "", err
+ }</span>
+ }
+ <span class="cov7" title="3">if err := f.Sync(); err != nil </span><span class="cov0" title="0">{
+ _ = f.Close()
+ return "", err
+ }</span>
+ <span class="cov7" title="3">if err := f.Close(); err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov7" title="3">if err := RunEditor(ed, path); err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov7" title="3">b, err := os.ReadFile(filepath.Clean(path))
+ if err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov7" title="3">return strings.TrimSpace(string(b)), nil</span>
}
</pre>
<pre class="file" id="file5" style="display: none">package hexaiaction
import (
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "time"
-
- "codeberg.org/snonux/hexai/internal/tmux"
- "golang.org/x/term"
+ "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
+ 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>
+ 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>
-var splitRunFn = tmux.SplitRun
-var osExecutableFn = os.Executable
-var runFn = Run
+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>
+ 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>
+ 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>
+ 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>
+ 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>
- }
+ 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>
+ 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">{
- var in io.Reader = stdin
- var out io.Writer = 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>
+ var in io.Reader = stdin
+ var out io.Writer = 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="file6" style="display: none">package hexaiaction
import (
- "bufio"
- "io"
- "strings"
+ "bufio"
+ "io"
+ "strings"
- "codeberg.org/snonux/hexai/internal/textutil"
+ "codeberg.org/snonux/hexai/internal/textutil"
)
// ParseInput splits raw stdin into optional diagnostics and selection/code.
@@ -1378,94 +1541,148 @@ func ExtractInstruction(sel string) (string, string) <span class="cov10" title="
<pre class="file" id="file7" style="display: none">package hexaiaction
import (
- "context"
- "strings"
- "time"
+ "context"
+ "strings"
+ "time"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/llm"
- "codeberg.org/snonux/hexai/internal/textutil"
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+ "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="cov9" title="10">{ return textutil.RenderTemplate(t, vars) }</span>
+func Render(t string, vars map[string]string) string <span class="cov8" title="14">{ return textutil.RenderTemplate(t, vars) }</span>
// StripFences removes surrounding markdown code fences.
-func StripFences(s string) string <span class="cov10" title="11">{ return textutil.StripCodeFences(s) }</span>
+func StripFences(s string) string <span class="cov8" title="15">{ 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 }
+
+func providerOf(c any) string <span class="cov8" title="14">{
+ if n, ok := c.(providerNamer); ok </span><span class="cov2" title="2">{
+ return n.Name()
+ }</span>
+ <span class="cov7" title="12">return "llm"</span>
}
-func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov6" title="4">{
- sys := cfg.PromptCodeActionRewriteSystem
- user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection})
- return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov5" title="6">{
+ 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="cov1" title="1">{
var b strings.Builder
- for i, d := range diags </span><span class="cov3" title="2">{
+ for i, d := range diags </span><span class="cov2" title="2">{
if strings.TrimSpace(d) == "" </span><span class="cov0" title="0">{
continue</span>
}
- <span class="cov3" title="2">b.WriteString(strings.TrimSpace(d))
+ <span class="cov2" title="2">b.WriteString(strings.TrimSpace(d))
if i &lt; len(diags)-1 </span><span class="cov1" title="1">{
b.WriteString("\n")
}</span>
}
<span class="cov1" title="1">sys := cfg.PromptCodeActionDiagnosticsSystem
user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection})
- return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span>
+ 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="cov3" title="2">{
- sys := cfg.PromptCodeActionDocumentSystem
- user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection})
- return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <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="cov0" title="0">{
- sys := cfg.PromptCodeActionSimplifySystem
- user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection})
- return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+ 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="cov3" title="2">{
+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))
+ 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="4">{
+ // 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="cov2" title="2">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">return strings.TrimSpace(StripFences(txt)), nil</span>
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ start := time.Now()
+ 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))
+ // Update tmux heartbeat with simple one-request stats
+ 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)
+ mins := time.Since(start).Minutes()
+ if mins &lt;= 0 </span><span class="cov0" title="0">{
+ mins = 0.001
+ }</span>
+ <span class="cov1" title="1">rpm := float64(1) / mins
+ _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv)))
+ return out, nil</span>
}
-func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov9" title="9">{
- msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- txt, err := client.Chat(ctx, msgs, opts...)
- if err != nil </span><span class="cov0" title="0">{
- return "", err
- }</span>
- <span class="cov9" title="9">return strings.TrimSpace(StripFences(txt)), nil</span>
+func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov8" title="13">{
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ start := time.Now()
+ txt, err := client.Chat(ctx, msgs, opts...)
+ if err != nil </span><span class="cov0" title="0">{
+ return "", err
+ }</span>
+ <span class="cov8" title="13">out := strings.TrimSpace(StripFences(txt))
+ // Update tmux heartbeat with simple one-request stats
+ sent := 0
+ for _, m := range msgs </span><span class="cov10" title="26">{
+ sent += len(m.Content)
+ }</span>
+ <span class="cov8" title="13">recv := len(out)
+ mins := time.Since(start).Minutes()
+ if mins &lt;= 0 </span><span class="cov0" title="0">{
+ mins = 0.001
+ }</span>
+ <span class="cov8" title="13">rpm := float64(1) / mins
+ _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv)))
+ return out, nil</span>
}
// reqOptsFrom builds LLM request options similar to LSP behavior.
-func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov9" title="9">{
- opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
- if cfg.CodingTemperature != nil </span><span class="cov7" title="5">{
- opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
- }</span>
- <span class="cov9" title="9">return opts</span>
+func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov8" title="13">{
+ opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
+ if cfg.CodingTemperature != nil </span><span class="cov7" title="9">{
+ opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
+ }</span>
+ <span class="cov8" title="13">return opts</span>
}
// Timeout helpers to mirror LSP behavior.
-func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov6" title="4">{
+func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov6" title="7">{
return context.WithTimeout(parent, 10*time.Second)
}</span>
@@ -1477,97 +1694,116 @@ func timeout8s(parent context.Context) (context.Context, context.CancelFunc) <sp
<pre class="file" id="file8" style="display: none">package hexaiaction
import (
- "context"
- "fmt"
- "io"
- "log"
- "strings"
-
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/editor"
- "codeberg.org/snonux/hexai/internal/logging"
- "codeberg.org/snonux/hexai/internal/llmutils"
- "codeberg.org/snonux/hexai/internal/tmux"
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "strings"
+
+ "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/tmux"
)
// Run executes the hexai-tmux-action command flow.
// seams for testability
-var chooseActionFn = RunTUI
-var newClientFromApp = llmutils.NewClientFromApp
+var (
+ chooseActionFn = RunTUI
+ newClientFromApp = llmutils.NewClientFromApp
+)
+
+// 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="cov7" title="4">{
- logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix)
- cfg := appconfig.Load(logger)
- 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="cov6" title="3">_ = tmux.SetStatus("hexai action ready " + cli.DefaultModel())
- var client chatDoer = cli
- parts, err := ParseInput(stdin)
+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.Load(logger)
+ 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) &gt; 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">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">_ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), cli.DefaultModel()))
+ 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)
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset)
return err
}</span>
- <span class="cov6" 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="cov6" title="3">kind, err := chooseActionFn()
- if err != nil </span><span class="cov0" title="0">{
- return err
- }</span>
- <span class="cov6" 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="cov6" title="3">io.WriteString(stdout, out)
- _ = tmux.SetStatus("✅ " + cli.DefaultModel())
- return nil</span>
-}
-
-func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov10" title="7">{
- switch kind </span>{
- case ActionSkip:<span class="cov4" title="2">
- return parts.Selection, nil</span>
- case ActionRewrite:<span class="cov4" title="2">
+ <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>
+}
+
+func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) <span class="cov10" title="10">{
+ switch kind </span>{
+ case ActionSkip:<span class="cov3" title="2">
+ return parts.Selection, nil</span>
+ case ActionRewrite:<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)
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset)
return parts.Selection, nil
}</span>
- <span class="cov4" title="2">cctx, cancel := timeout10s(ctx)
+ <span class="cov3" title="2">cctx, cancel := timeout10s(ctx)
defer cancel()
return runRewrite(cctx, cfg, client, instr, cleaned)</span>
case ActionDiagnostics:<span class="cov0" title="0">
cctx, cancel := timeout10s(ctx)
defer cancel()
return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection)</span>
- case ActionDocument:<span class="cov1" title="1">
- cctx, cancel := timeout10s(ctx)
- defer cancel()
- return runDocument(cctx, cfg, client, parts.Selection)</span>
- case ActionGoTest:<span class="cov1" title="1">
- cctx, cancel := timeout8s(ctx)
- defer cancel()
- return runGoTest(cctx, cfg, client, parts.Selection)</span>
- case ActionSimplify:<span class="cov0" title="0">
- cctx, cancel := timeout10s(ctx)
- defer cancel()
- return runSimplify(cctx, cfg, client, parts.Selection)</span>
- case ActionCustom:<span class="cov1" title="1">
- cctx, cancel := timeout10s(ctx)
- defer cancel()
- // Open editor for free-form instruction
- 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 runRewrite(cctx, cfg, client, prompt, parts.Selection)</span>
- default:<span class="cov0" title="0">
- return parts.Selection, nil</span>
- }
+ case ActionDocument:<span class="cov1" title="1">
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runDocument(cctx, cfg, client, parts.Selection)</span>
+ case ActionGoTest:<span class="cov1" title="1">
+ cctx, cancel := timeout8s(ctx)
+ defer cancel()
+ return runGoTest(cctx, cfg, client, parts.Selection)</span>
+ case ActionSimplify:<span class="cov0" title="0">
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runSimplify(cctx, cfg, client, parts.Selection)</span>
+ case ActionCustom:<span class="cov6" title="4">
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ if selectedCustom != nil </span><span class="cov5" title="3">{
+ // Run configured custom action
+ out, err := runCustom(cctx, cfg, client, *selectedCustom, parts)
+ selectedCustom = nil // clear after use
+ return out, err
+ }</span>
+ // Fallback: open editor for free-form instruction
+ <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 runRewrite(cctx, cfg, client, prompt, parts.Selection)</span>
+ default:<span class="cov0" title="0">
+ return parts.Selection, nil</span>
+ }
}
// client construction is shared via internal/llmutils
@@ -1576,11 +1812,11 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a
<pre class="file" id="file9" style="display: none">package hexaiaction
import (
- "fmt"
- "strings"
+ "fmt"
+ "strings"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
)
// item implements list.Item
@@ -1592,7 +1828,7 @@ type item struct {
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="cov8" title="3">{ return i.title }</span>
+func (i item) FilterValue() string <span class="cov6" title="3">{ return i.title }</span>
type model struct {
list list.Model
@@ -1600,21 +1836,21 @@ type model struct {
done bool
}
-func newModel() model <span class="cov10" title="4">{
- 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: ActionCustom, 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}
+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: ActionCustom, 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>
@@ -1631,44 +1867,48 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov1" title
return m, cmd</span>
}
-func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" 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 &gt; 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 &lt; len(items); i++ </span><span class="cov1" title="1">{
- if it, ok := items[i].(item); ok &amp;&amp; strings.ToLower(string(it.hotkey)) == low </span><span class="cov1" title="1">{
- m.list.Select(i)
- m.chosen = it.kind
+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>
+ 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 &gt; 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 &lt; len(items); i++ </span><span class="cov1" title="1">{
+ if it, ok := items[i].(item); ok &amp;&amp; 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 &gt; 0 </span><span class="cov1" title="1">{
+ m.list.Select(n - 1)
+ }</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 &gt; 0 </span><span class="cov1" title="1">{ m.list.Select(n - 1) }</span>
- }
- <span class="cov1" title="1">return m, nil</span>
+ <span class="cov1" title="1">return m, nil</span>
}
func (m model) View() string <span class="cov1" title="1">{
@@ -1698,94 +1938,172 @@ func RunTUI() (ActionKind, error) <span class="cov0" title="0">{
<pre class="file" id="file10" style="display: none">package hexaiaction
import (
- "fmt"
- "io"
+ "unicode/utf8"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "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 &amp;&amp; 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 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 &amp;&amp; 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 = &amp;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="file11" 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)
+ hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
+ cursorStyle = lipgloss.NewStyle().Bold(true)
)
-func (oneLineDelegate) Height() int <span class="cov8" title="18">{ return 1 }</span>
-func (oneLineDelegate) Spacing() int <span class="cov10" title="32">{ return 0 }</span>
+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("&gt; ")
- }</span>
- <span class="cov2" title="2">fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span>
+ 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("&gt; ")
+ }</span>
+ <span class="cov2" title="2">fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span>
}
</pre>
- <pre class="file" id="file11" style="display: none">// Summary: Hexai CLI runner; reads input, creates an LLM client, builds messages,
+ <pre class="file" id="file12" 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 (
- "bufio"
- "context"
- "fmt"
- "io"
- "log"
- "os"
- "strings"
- "time"
-
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/editor"
- "codeberg.org/snonux/hexai/internal/logging"
- "codeberg.org/snonux/hexai/internal/llm"
- "codeberg.org/snonux/hexai/internal/llmutils"
- "codeberg.org/snonux/hexai/internal/tmux"
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+ "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/tmux"
)
// 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="3">{
- // Load configuration with a logger so file-based config is respected.
- logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix)
- cfg := appconfig.Load(logger)
- client, err := newClientFromApp(cfg)
+func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov5" title="3">{
+ // Load configuration with a logger so file-based config is respected.
+ logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix)
+ cfg := appconfig.Load(logger)
+ client, err := newClientFromApp(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>
- // No args: open editor to capture a prompt, then combine with stdin as usual.
- <span class="cov4" title="2">if len(args) == 0 </span><span class="cov1" title="1">{
- if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil &amp;&amp; strings.TrimSpace(prompt) != "" </span><span class="cov1" title="1">{
- args = []string{prompt}
- }</span> else <span class="cov0" title="0">{
- // If editor fails or empty, continue; readInput will likely error if no stdin either.
- }</span>
- }
- // Inline the flow here to use configured CLI prompts.
- <span class="cov4" title="2">input, rerr := readInput(stdin, args)
+ // No args: open editor to capture a prompt, then combine with stdin as usual.
+ <span class="cov3" title="2">if len(args) == 0 </span><span class="cov1" title="1">{
+ if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil &amp;&amp; strings.TrimSpace(prompt) != "" </span><span class="cov1" title="1">{
+ args = []string{prompt}
+ }</span> else <span class="cov0" title="0">{
+ // If editor fails or empty, continue; readInput will likely error if no stdin either.
+ }</span>
+ }
+ // Inline the flow here to use configured CLI prompts.
+ <span class="cov3" title="2">input, rerr := readInput(stdin, args)
if rerr != nil </span><span class="cov0" title="0">{
fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset)
return rerr
}</span>
- <span class="cov4" title="2">printProviderInfo(stderr, client)
+ <span class="cov3" title="2">printProviderInfo(stderr, client)
msgs := buildMessagesFromConfig(cfg, input)
if err := runChat(ctx, client, 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="cov4" title="2">return nil</span>
+ <span class="cov3" title="2">return nil</span>
}
// RunWithClient executes the CLI flow using an already-constructed client.
@@ -1806,19 +2124,19 @@ func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout,
}
// readInput reads from stdin and args, then combines them per CLI rules.
-func readInput(stdin io.Reader, args []string) (string, error) <span class="cov10" title="7">{
+func readInput(stdin io.Reader, args []string) (string, error) <span class="cov8" title="7">{
var stdinData string
- if fi, err := os.Stdin.Stat(); err == nil &amp;&amp; (fi.Mode()&amp;os.ModeCharDevice) == 0 </span><span class="cov7" title="4">{
+ if fi, err := os.Stdin.Stat(); err == nil &amp;&amp; (fi.Mode()&amp;os.ModeCharDevice) == 0 </span><span class="cov6" title="4">{
b, _ := io.ReadAll(bufio.NewReader(stdin))
stdinData = strings.TrimSpace(string(b))
}</span>
- <span class="cov10" title="7">argData := strings.TrimSpace(strings.Join(args, " "))
+ <span class="cov8" title="7">argData := strings.TrimSpace(strings.Join(args, " "))
switch </span>{
case stdinData != "" &amp;&amp; argData != "":<span class="cov1" title="1">
return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil</span>
case stdinData != "":<span class="cov1" title="1">
return stdinData, nil</span>
- case argData != "":<span class="cov7" title="4">
+ case argData != "":<span class="cov6" title="4">
return argData, nil</span>
default:<span class="cov1" title="1">
return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")</span>
@@ -1829,20 +2147,20 @@ func readInput(stdin io.Reader, args []string) (string, error) <span class="cov1
// client construction moved to internal/llmutils
// buildMessages creates system and user messages based on input content.
-func buildMessages(input string) []llm.Message <span class="cov9" title="6">{
+func buildMessages(input string) []llm.Message <span class="cov8" 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="cov9" title="6">return []llm.Message{
+ <span class="cov8" 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="4">{
+func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <span class="cov6" title="4">{
lower := strings.ToLower(input)
system := cfg.PromptCLIDefaultSystem
if strings.Contains(lower, "explain") </span><span class="cov1" title="1">{
@@ -1850,55 +2168,66 @@ func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message <spa
system = cfg.PromptCLIExplainSystem
}</span>
}
- <span class="cov7" title="4">return []llm.Message{
+ <span class="cov6" title="4">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, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov10" title="7">{
- start := time.Now()
- // Best-effort tmux status update
- _ = tmux.SetStatus("⏳ " + client.Name() + ":" + client.DefaultModel())
- var output string
- if s, ok := client.(llm.Streamer); ok </span><span class="cov4" title="2">{
+func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov8" title="7">{
+ start := time.Now()
+ // Best-effort tmux status update (colored start heartbeat)
+ _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), client.DefaultModel()))
+ var output string
+ if s, ok := client.(llm.Streamer); ok </span><span class="cov3" title="2">{
var b strings.Builder
- if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov8" title="5">{
+ if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov7" title="5">{
b.WriteString(chunk)
fmt.Fprint(out, chunk)
}</span>); err != nil <span class="cov0" title="0">{
return err
}</span>
- <span class="cov4" title="2">output = b.String()</span>
- } else<span class="cov8" title="5"> {
+ <span class="cov3" title="2">output = b.String()</span>
+ } else<span class="cov7" title="5"> {
txt, err := client.Chat(ctx, msgs)
- if err != nil </span><span class="cov4" title="2">{
+ if err != nil </span><span class="cov3" title="2">{
return err
}</span>
- <span class="cov6" title="3">output = txt
+ <span class="cov5" title="3">output = txt
fmt.Fprint(out, output)</span>
}
- <span class="cov8" title="5">dur := time.Since(start)
- fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n",
- client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output))
- _ = tmux.SetStatus("✅ " + client.DefaultModel() + " " + dur.Round(time.Millisecond).String())
- return nil</span>
+ <span class="cov7" title="5">dur := time.Since(start)
+ // Compute simple stats for tmux heartbeat
+ sent := 0
+ for _, m := range msgs </span><span class="cov10" title="9">{
+ sent += len(m.Content)
+ }</span>
+ <span class="cov7" title="5">recv := len(output)
+ mins := dur.Minutes()
+ if mins &lt;= 0 </span><span class="cov0" title="0">{
+ mins = 0.001
+ }</span>
+ <span class="cov7" title="5">rpm := float64(1) / mins
+ fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n",
+ client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), sent, recv)
+ _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(client.Name(), client.DefaultModel(), 1, rpm, int64(sent), int64(recv)))
+ return nil</span>
}
// printProviderInfo writes the provider/model line to stderr.
-func printProviderInfo(errw io.Writer, client llm.Client) <span class="cov7" title="4">{
- fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel())
+func printProviderInfo(errw io.Writer, client llm.Client) <span class="cov6" title="4">{
+ fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel())
}</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="cov4" title="2">{ return newClientFromApp(cfg) }</span>
+func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov3" title="2">{ return newClientFromApp(cfg) }</span>
</pre>
- <pre class="file" id="file12" style="display: none">// Summary: Hexai LSP runner; configures logging, loads config, builds the LLM client,
+ <pre class="file" id="file13" 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
@@ -1934,14 +2263,20 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er
}
<span class="cov1" title="1">logging.Bind(logger)
cfg := appconfig.Load(logger)
- return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil)</span>
+ if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{
+ logger.Fatalf("invalid config: %v", err)
+ }</span>
+ <span class="cov1" title="1">return RunWithFactory(logPath, 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, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov10" title="7">{
normalizeLoggingConfig(&amp;cfg)
- client = buildClientIfNil(cfg, client)
+ if err := cfg.Validate(); err != nil </span><span class="cov0" title="0">{
+ logger.Fatalf("invalid config: %v", err)
+ }</span>
+ <span class="cov10" title="7">client = buildClientIfNil(cfg, client)
factory = ensureFactory(factory)
opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client)
@@ -2006,7 +2341,23 @@ func ensureFactory(factory ServerFactory) ServerFactory <span class="cov10" titl
}
func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions <span class="cov10" title="7">{
- return lsp.ServerOptions{
+ // Map custom actions from appconfig to lsp type
+ var customs []lsp.CustomAction
+ if len(cfg.CustomActions) &gt; 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="7">return lsp.ServerOptions{
LogContext: logContext,
MaxTokens: cfg.MaxTokens,
ContextMode: cfg.ContextMode,
@@ -2042,11 +2393,12 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls
PromptGoTestUser: cfg.PromptCodeActionGoTestUser,
PromptSimplifySystem: cfg.PromptCodeActionSimplifySystem,
PromptSimplifyUser: cfg.PromptCodeActionSimplifyUser,
- }
-}</span>
+ CustomActions: customs,
+ }</span>
+}
</pre>
- <pre class="file" id="file13" style="display: none">// Summary: GitHub Copilot client for chat and Codex-style code completion.
+ <pre class="file" id="file14" style="display: none">// Summary: GitHub Copilot client for chat and Codex-style code completion.
package llm
import (
@@ -2441,7 +2793,7 @@ func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix
// (no streaming decoder needed; we parse whole body lines)
</pre>
- <pre class="file" id="file14" style="display: none">// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat.
+ <pre class="file" id="file15" style="display: none">// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat.
package llm
import (
@@ -2659,7 +3011,7 @@ func handleOllamaNon2xx(resp *http.Response, start time.Time) error <span class=
}
</pre>
- <pre class="file" id="file15" style="display: none">// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging.
+ <pre class="file" id="file16" style="display: none">// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging.
package llm
import (
@@ -2962,7 +3314,7 @@ func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string
}
</pre>
- <pre class="file" id="file16" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config.
+ <pre class="file" id="file17" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config.
package llm
import (
@@ -3020,8 +3372,8 @@ type Options struct {
type RequestOption func(*Options)
func WithModel(model string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Model = model }</span> }
-func WithTemperature(t float64) RequestOption <span class="cov5" title="6">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Temperature = t }</span> }
-func WithMaxTokens(n int) RequestOption <span class="cov10" title="34">{ return func(o *Options) </span><span class="cov1" title="1">{ o.MaxTokens = n }</span> }
+func WithTemperature(t float64) RequestOption <span class="cov6" title="10">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Temperature = t }</span> }
+func WithMaxTokens(n int) RequestOption <span class="cov10" title="42">{ return func(o *Options) </span><span class="cov1" title="1">{ o.MaxTokens = n }</span> }
func WithStop(stop ...string) RequestOption <span class="cov1" title="1">{
return func(o *Options) </span><span class="cov1" title="1">{ o.Stop = append([]string{}, stop...) }</span>
}
@@ -3052,12 +3404,12 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro
p = "openai"
}</span>
<span class="cov8" title="19">switch p </span>{
- case "openai":<span class="cov7" title="12">
- if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="5">{
+ case "openai":<span class="cov6" title="12">
+ if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov4" title="5">{
return nil, errors.New("missing OPENAI_API_KEY for provider openai")
}</span>
// Set coding-friendly default temperature if none provided
- <span class="cov5" title="7">if cfg.OpenAITemperature == nil </span><span class="cov5" title="5">{
+ <span class="cov5" title="7">if cfg.OpenAITemperature == nil </span><span class="cov4" title="5">{
t := 0.2
cfg.OpenAITemperature = &amp;t
}</span>
@@ -3083,7 +3435,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, erro
}
</pre>
- <pre class="file" id="file17" style="display: none">package llm
+ <pre class="file" id="file18" style="display: none">package llm
import "errors"
@@ -3091,44 +3443,43 @@ import "errors"
func nilStringErr(msg string) (string, error) <span class="cov10" title="2">{ return "", errors.New(msg) }</span>
</pre>
- <pre class="file" id="file18" style="display: none">package llmutils
+ <pre class="file" id="file19" style="display: none">package llmutils
import (
- "os"
- "strings"
+ "os"
+ "strings"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/llm"
+ "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="6">{
- llmCfg := llm.Config{
- Provider: cfg.Provider,
- OpenAIBaseURL: cfg.OpenAIBaseURL,
- OpenAIModel: cfg.OpenAIModel,
- OpenAITemperature: cfg.OpenAITemperature,
- OllamaBaseURL: cfg.OllamaBaseURL,
- OllamaModel: cfg.OllamaModel,
- OllamaTemperature: cfg.OllamaTemperature,
- CopilotBaseURL: cfg.CopilotBaseURL,
- CopilotModel: cfg.CopilotModel,
- CopilotTemperature: cfg.CopilotTemperature,
- }
- oaKey := os.Getenv("HEXAI_OPENAI_API_KEY")
- if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="5">{
- oaKey = os.Getenv("OPENAI_API_KEY")
- }</span>
- <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY")
- if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{
- cpKey = os.Getenv("COPILOT_API_KEY")
- }</span>
- <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span>
+ llmCfg := llm.Config{
+ Provider: cfg.Provider,
+ OpenAIBaseURL: cfg.OpenAIBaseURL,
+ OpenAIModel: cfg.OpenAIModel,
+ OpenAITemperature: cfg.OpenAITemperature,
+ OllamaBaseURL: cfg.OllamaBaseURL,
+ OllamaModel: cfg.OllamaModel,
+ OllamaTemperature: cfg.OllamaTemperature,
+ CopilotBaseURL: cfg.CopilotBaseURL,
+ CopilotModel: cfg.CopilotModel,
+ CopilotTemperature: cfg.CopilotTemperature,
+ }
+ oaKey := os.Getenv("HEXAI_OPENAI_API_KEY")
+ if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="5">{
+ oaKey = os.Getenv("OPENAI_API_KEY")
+ }</span>
+ <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY")
+ if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{
+ cpKey = os.Getenv("COPILOT_API_KEY")
+ }</span>
+ <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span>
}
-
</pre>
- <pre class="file" id="file19" style="display: none">package logging
+ <pre class="file" id="file20" style="display: none">package logging
// ChatLogger provides a structured way to log chat interactions.
type ChatLogger struct {
@@ -3159,7 +3510,7 @@ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens
}
</pre>
- <pre class="file" id="file20" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation.
+ <pre class="file" id="file21" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation.
package logging
import (
@@ -3188,11 +3539,11 @@ var std *log.Logger
func Bind(l *log.Logger) <span class="cov2" title="3">{ 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="144">{
- if std == nil </span><span class="cov9" title="103">{
+func Logf(prefix, format string, args ...any) <span class="cov10" title="171">{
+ if std == nil </span><span class="cov9" title="117">{
return
}</span>
- <span class="cov7" title="41">msg := fmt.Sprintf(format, args...)
+ <span class="cov7" title="54">msg := fmt.Sprintf(format, args...)
std.Print(AnsiBase + prefix + msg + AnsiReset)</span>
}
@@ -3211,11 +3562,11 @@ func PreviewForLog(s string) string <span class="cov7" title="32">{
}</span>
<span class="cov2" title="3">return s[:logPreviewLimit] + "…"</span>
}
- <span class="cov7" title="29">return s</span>
+ <span class="cov6" title="29">return s</span>
}
</pre>
- <pre class="file" id="file21" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic.
+ <pre class="file" id="file22" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic.
package lsp
import (
@@ -3301,7 +3652,7 @@ func truncateToApproxTokens(text string, maxTokens int) string <span class="cov8
}
</pre>
- <pre class="file" id="file22" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits.
+ <pre class="file" id="file23" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits.
package lsp
import (
@@ -3315,7 +3666,7 @@ type document struct {
lines []string
}
-func (s *Server) setDocument(uri, text string) <span class="cov8" title="32">{
+func (s *Server) setDocument(uri, text string) <span class="cov8" title="34">{
s.mu.Lock()
defer s.mu.Unlock()
s.docs[uri] = &amp;document{uri: uri, text: text, lines: splitLines(text)}
@@ -3333,45 +3684,45 @@ func (s *Server) markActivity() <span class="cov4" title="4">{
s.mu.Unlock()
}</span>
-func (s *Server) getDocument(uri string) *document <span class="cov10" title="52">{
+func (s *Server) getDocument(uri string) *document <span class="cov10" title="56">{
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="cov9" title="42">{
+func splitLines(sx string) []string <span class="cov9" title="46">{
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="cov4" title="5">{
+func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) <span class="cov5" title="7">{
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 </span><span class="cov1" title="1">{
return "", "", "", ""
}</span>
- <span class="cov4" title="4">idx := pos.Line
+ <span class="cov5" title="6">idx := pos.Line
if idx &lt; 0 </span><span class="cov0" title="0">{
idx = 0
}</span>
- <span class="cov4" title="4">if idx &gt;= len(d.lines) </span><span class="cov0" title="0">{
+ <span class="cov5" title="6">if idx &gt;= len(d.lines) </span><span class="cov0" title="0">{
idx = len(d.lines) - 1
}</span>
- <span class="cov4" title="4">current = d.lines[idx]
- if idx-1 &gt;= 0 </span><span class="cov4" title="4">{
+ <span class="cov5" title="6">current = d.lines[idx]
+ if idx-1 &gt;= 0 </span><span class="cov5" title="6">{
above = d.lines[idx-1]
}</span>
- <span class="cov4" title="4">if idx+1 &lt; len(d.lines) </span><span class="cov4" title="4">{
+ <span class="cov5" title="6">if idx+1 &lt; len(d.lines) </span><span class="cov5" title="6">{
below = d.lines[idx+1]
}</span>
- <span class="cov4" title="4">for i := idx; i &gt;= 0; i-- </span><span class="cov5" title="6">{
+ <span class="cov5" title="6">for i := idx; i &gt;= 0; i-- </span><span class="cov5" title="8">{
line := strings.TrimSpace(d.lines[i])
- if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov4" title="4">{
+ if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov5" title="6">{
funcCtx = line
break</span>
}
}
- <span class="cov4" title="4">return above, current, below, funcCtx</span>
+ <span class="cov5" title="6">return above, current, below, funcCtx</span>
}
// isDefiningNewFunction returns true when the cursor appears to be within
@@ -3422,9 +3773,9 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span clas
<span class="cov1" title="1">return true</span>
}
-func hasAny(s string, needles []string) bool <span class="cov5" title="6">{
- for _, n := range needles </span><span class="cov7" title="16">{
- if strings.Contains(s, n) </span><span class="cov4" title="4">{
+func hasAny(s string, needles []string) bool <span class="cov5" title="8">{
+ for _, n := range needles </span><span class="cov7" title="18">{
+ if strings.Contains(s, n) </span><span class="cov5" title="6">{
return true
}</span>
}
@@ -3448,13 +3799,13 @@ func firstLine(s string) string <span class="cov8" title="25">{
}
</pre>
- <pre class="file" id="file23" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled.
+ <pre class="file" id="file24" 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"
+ "encoding/json"
+ "fmt"
+ "strings"
)
func (s *Server) handle(req Request) <span class="cov2" title="2">{
@@ -3475,15 +3826,15 @@ func (s *Server) handle(req Request) <span class="cov2" title="2">{
// Preference order on each line: strict ;text; marker (no inner spaces), then
// a line comment (//, #, --). Returns the instruction string and the selection
// text cleaned of the matched instruction marker or comment.
-func instructionFromSelection(sel string) (string, string) <span class="cov3" title="3">{
- lines := splitLines(sel)
- for idx, line := range lines </span><span class="cov3" title="3">{
- if instr, cleaned, ok := findFirstInstructionInLine(line); ok &amp;&amp; strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{
- lines[idx] = cleaned
- return instr, strings.Join(lines, "\n")
- }</span>
- }
- <span class="cov2" title="2">return "", sel</span>
+func instructionFromSelection(sel string) (string, string) <span class="cov5" title="5">{
+ lines := splitLines(sel)
+ for idx, line := range lines </span><span class="cov5" title="5">{
+ if instr, cleaned, ok := findFirstInstructionInLine(line); ok &amp;&amp; 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
@@ -3495,52 +3846,52 @@ func instructionFromSelection(sel string) (string, string) <span class="cov3" ti
// - // text
// - # text
// - -- text
-func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="22">{
- type cand struct {
- start, end int
- text string
- }
- cands := []cand{}
- if t, l, r, ok := findStrictInlineTag(line); ok </span><span class="cov5" title="6">{
- cands = append(cands, cand{start: l, end: r, text: t})
- }</span>
- <span class="cov8" title="22">if i := strings.Index(line, "/*"); i &gt;= 0 </span><span class="cov2" title="2">{
- if j := strings.Index(line[i+2:], "*/"); j &gt;= 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="22">if i := strings.Index(line, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
- if j := strings.Index(line[i+4:], "--&gt;"); j &gt;= 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="22">if i := strings.Index(line, "//"); i &gt;= 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="22">if i := strings.Index(line, "#"); i &gt;= 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="22">if i := strings.Index(line, "--"); i &gt;= 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="22">if len(cands) == 0 </span><span class="cov5" title="6">{
- 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 &gt;= 0 &amp;&amp; (best.start &lt; 0 || c.start &lt; 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>
+func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="24">{
+ type cand struct {
+ start, end int
+ text string
+ }
+ cands := []cand{}
+ if t, l, r, ok := findStrictInlineTag(line); 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 &gt;= 0 </span><span class="cov2" title="2">{
+ if j := strings.Index(line[i+2:], "*/"); j &gt;= 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, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
+ if j := strings.Index(line[i+4:], "--&gt;"); j &gt;= 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 &gt;= 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 &gt;= 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 &gt;= 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="cov6" 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 &gt;= 0 &amp;&amp; (best.start &lt; 0 || c.start &lt; 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
@@ -3564,7 +3915,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b
// handleCompletion moved to handlers_completion.go
-func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov7" title="11">{
+func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov7" title="13">{
resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err}
s.writeMessage(resp)
}</span>
@@ -3895,7 +4246,7 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span c
}</span>
</pre>
- <pre class="file" id="file24" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity.
+ <pre class="file" id="file25" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity.
package lsp
import (
@@ -3911,7 +4262,7 @@ import (
"codeberg.org/snonux/hexai/internal/logging"
)
-func (s *Server) handleCodeAction(req Request) <span class="cov3" title="3">{
+func (s *Server) handleCodeAction(req Request) <span class="cov4" title="5">{
var p CodeActionParams
if err := json.Unmarshal(req.Params, &amp;p); err != nil </span><span class="cov0" title="0">{
if len(req.ID) != 0 </span><span class="cov0" title="0">{
@@ -3919,52 +4270,106 @@ func (s *Server) handleCodeAction(req Request) <span class="cov3" title="3">{
}</span>
<span class="cov0" title="0">return</span>
}
- <span class="cov3" title="3">d := s.getDocument(p.TextDocument.URI)
+ <span class="cov4" title="5">d := s.getDocument(p.TextDocument.URI)
if d == nil || len(d.lines) == 0 || s.llmClient == 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="cov1" title="1">sel := extractRangeText(d, p.Range)
+ <span class="cov3" title="3">sel := extractRangeText(d, p.Range)
- actions := make([]CodeAction, 0, 5)
+ 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="cov1" title="1">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{
+ <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="cov1" title="1">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov1" title="1">{
- actions = append(actions, *a)
- }</span>
- <span class="cov1" title="1">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov1" title="1">{
- actions = append(actions, *a)
- }</span>
- <span class="cov1" title="1">if a := s.buildSimplifyCodeAction(p, sel); a != nil </span><span class="cov1" title="1">{
- actions = append(actions, *a)
- }</span>
- <span class="cov1" title="1">if len(req.ID) != 0 </span><span class="cov1" title="1">{
+ <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(&amp;actions, p, sel)
+ if len(req.ID) != 0 </span><span class="cov3" title="3">{
s.reply(req.ID, actions, nil)
}</span>
}
-func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov1" title="1">{
- if strings.TrimSpace(sel) == "" </span><span class="cov0" title="0">{
- return nil
- }</span>
- <span class="cov1" title="1">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 &amp;ca</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">{
+ if len(s.customActions) == 0 </span><span class="cov1" title="1">{
+ return
+ }</span>
+ <span class="cov2" title="2">diags := s.diagnosticsInRange(p.Context, p.Range)
+ for _, ca := range s.customActions </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 &amp;ca</span>
}
-func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="3">{
+func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov4" title="5">{
if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{
payload := struct {
Type string `json:"type"`
@@ -3977,15 +4382,15 @@ func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAct
ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw}
return &amp;ca
}</span>
- <span class="cov2" title="2">return nil</span>
+ <span class="cov3" title="4">return nil</span>
}
-func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="4">{
+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="cov2" title="2">payload := struct {
+ <span class="cov3" title="4">payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
@@ -3997,12 +4402,13 @@ func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *Cod
return &amp;ca</span>
}
-func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov5" title="12">{
- if s.llmClient == nil || len(ca.Data) == 0 </span><span class="cov0" title="0">{
+func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov6" title="17">{
+ if s.llmClient == nil || len(ca.Data) == 0 </span><span class="cov1" title="1">{
return ca, false
}</span>
- <span class="cov5" title="12">var payload struct {
+ <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"`
@@ -4012,7 +4418,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class
if err := json.Unmarshal(ca.Data, &amp;payload); err != nil </span><span class="cov0" title="0">{
return ca, false
}</span>
- <span class="cov5" title="12">switch payload.Type </span>{
+ <span class="cov6" title="16">switch payload.Type </span>{
case "rewrite":<span class="cov3" title="4">
sys := s.promptRewriteSystem
user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.Instruction, "selection": payload.Selection})
@@ -4020,7 +4426,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
- if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov3" title="4">{
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="4">{
if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov3" title="4">{
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &amp;edit
@@ -4045,7 +4451,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
- if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov4" title="5">{
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov4" title="5">{
if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov4" title="5">{
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &amp;edit
@@ -4061,7 +4467,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
- if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov3" title="3">{
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="3">{
if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov3" title="3">{
edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
ca.Edit = &amp;edit
@@ -4070,35 +4476,86 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class
} else<span class="cov0" title="0"> {
logging.Logf("lsp ", "codeAction document llm error: %v", err)
}</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 = &amp;edit
- // After edit is applied, ask client to jump to new test function
- ca.Command = &amp;Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}}
- // Also send a server-initiated showDocument shortly after resolve to cover
- // clients that do not execute commands from code actions.
- s.deferShowDocument(jumpURI, jumpRange)
- return ca, true
- }</span>
- case "simplify":<span class="cov0" title="0">
- sys := s.promptRewriteSystem
- // Reuse rewrite user template with a fixed instruction
- user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection})
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- opts := s.llmRequestOpts()
- if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov0" title="0">{
- if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov0" title="0">{
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
- ca.Edit = &amp;edit
- return ca, true
- }</span>
- } else<span class="cov0" title="0"> {
- logging.Logf("lsp ", "codeAction simplify llm error: %v", err)
- }</span>
- }
- <span class="cov0" title="0">return ca, false</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 = &amp;edit
+ // After edit is applied, ask client to jump to new test function
+ ca.Command = &amp;Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}}
+ // Also send a server-initiated showDocument shortly after resolve to cover
+ // clients that do not execute commands from code actions.
+ s.deferShowDocument(jumpURI, jumpRange)
+ return ca, true
+ }</span>
+ case "simplify":<span class="cov0" title="0">
+ sys := s.promptRewriteSystem
+ // Reuse rewrite user template with a fixed instruction
+ user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": "Simplify and improve the code while preserving behavior. Return only the improved code.", "selection": payload.Selection})
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ opts := s.llmRequestOpts()
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov0" title="0">{
+ if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov0" title="0">{
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
+ ca.Edit = &amp;edit
+ return ca, true
+ }</span>
+ } else<span class="cov0" title="0"> {
+ logging.Logf("lsp ", "codeAction simplify llm error: %v", err)
+ }</span>
+ case "custom":<span class="cov3" title="4">
+ // Lookup action by ID
+ var action *CustomAction
+ for i := range s.customActions </span><span class="cov4" title="5">{
+ if s.customActions[i].ID == payload.ID </span><span class="cov3" title="4">{
+ action = &amp;s.customActions[i]
+ break</span>
+ }
+ }
+ <span class="cov3" title="4">if action == nil </span><span class="cov0" title="0">{
+ return ca, false
+ }</span>
+ // Build messages
+ <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 = s.promptRewriteSystem
+ }</span>
+ <span class="cov1" title="1">var diagList string
+ if len(payload.Diagnostics) &gt; 0 </span><span class="cov1" title="1">{
+ var b strings.Builder
+ for i, dgn := range payload.Diagnostics </span><span class="cov1" title="1">{
+ if dgn.Source != "" </span><span class="cov0" title="0">{
+ fmt.Fprintf(&amp;b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message)
+ }</span> else<span class="cov1" title="1"> {
+ fmt.Fprintf(&amp;b, "%d. %s\n", i+1, dgn.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": diagList})</span>
+ } else<span class="cov3" title="3"> {
+ // Use rewrite templates with fixed instruction
+ sys = s.promptRewriteSystem
+ user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection})
+ }</span>
+ <span class="cov3" title="4">ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ opts := s.llmRequestOpts()
+ if text, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov3" title="3">{
+ if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov2" title="2">{
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}}
+ ca.Edit = &amp;edit
+ return ca, true
+ }</span>
+ } else<span class="cov1" title="1"> {
+ logging.Logf("lsp ", "codeAction custom id=%s llm error: %v", action.ID, err)
+ }</span>
+ }
+ <span class="cov2" title="2">return ca, false</span>
}
func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title="2">{
@@ -4119,77 +4576,77 @@ func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title="
// 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="cov4" title="7">{
+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="cov3" title="4">var ctx CodeActionContext
+ <span class="cov5" title="8">var ctx CodeActionContext
if err := json.Unmarshal(ctxRaw, &amp;ctx); err != nil </span><span class="cov0" title="0">{
return nil
}</span>
- <span class="cov3" title="4">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{
+ <span class="cov5" title="8">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{
return nil
}</span>
- <span class="cov3" title="4">out := make([]Diagnostic, 0, len(ctx.Diagnostics))
- for _, d := range ctx.Diagnostics </span><span class="cov4" title="7">{
- if rangesOverlap(d.Range, sel) </span><span class="cov3" title="4">{
+ <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="cov3" title="4">return out</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="cov5" title="10">{
+func rangesOverlap(a, b Range) bool <span class="cov6" title="14">{
// Normalize ordering
- if greaterPos(a.Start, a.End) </span><span class="cov0" title="0">{
+ if greaterPos(a.Start, a.End) </span><span class="cov3" title="4">{
a.Start, a.End = a.End, a.Start
}</span>
- <span class="cov5" title="10">if greaterPos(b.Start, b.End) </span><span class="cov0" title="0">{
+ <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="cov5" title="10">if lessPos(a.End, b.Start) </span><span class="cov3" title="3">{
+ <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="cov4" title="7">if lessPos(b.End, a.Start) </span><span class="cov1" title="1">{
+ <span class="cov5" title="11">if lessPos(b.End, a.Start) </span><span class="cov1" title="1">{
return false
}</span>
- <span class="cov4" title="6">return true</span>
+ <span class="cov5" title="10">return true</span>
}
-func lessPos(p, q Position) bool <span class="cov6" title="19">{
- if p.Line != q.Line </span><span class="cov6" title="14">{
+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 &lt; q.Line
}</span>
- <span class="cov4" title="5">return p.Character &lt; q.Character</span>
+ <span class="cov5" title="9">return p.Character &lt; q.Character</span>
}
-func greaterPos(p, q Position) bool <span class="cov6" title="22">{
- if p.Line != q.Line </span><span class="cov5" title="11">{
+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 &gt; q.Line
}</span>
- <span class="cov5" title="11">return p.Character &gt; q.Character</span>
+ <span class="cov6" title="15">return p.Character &gt; q.Character</span>
}
// --- Go unit test code action ---
-func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov3" title="3">{
+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="cov3" title="3">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov1" title="1">{
+ <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="cov2" title="2">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start)
+ <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="cov2" title="2">payload := struct {
+ <span class="cov3" title="4">payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
@@ -4200,14 +4657,14 @@ func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span
}
// buildDocumentCodeAction offers to document the selected code by injecting comments.
-func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov2" title="2">{
+func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="4">{
if s.llmClient == nil </span><span class="cov0" title="0">{
return nil
}</span>
- <span class="cov2" title="2">if strings.TrimSpace(sel) == "" </span><span class="cov0" title="0">{
+ <span class="cov3" title="4">if strings.TrimSpace(sel) == "" </span><span class="cov1" title="1">{
return nil
}</span>
- <span class="cov2" title="2">payload := struct {
+ <span class="cov3" title="3">payload := struct {
Type string `json:"type"`
URI string `json:"uri"`
Range Range `json:"range"`
@@ -4405,7 +4862,7 @@ func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov
defer cancel()
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
opts := s.llmRequestOpts()
- if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov2" title="2">{
+ if out, err := s.chatWithStats(ctx, messages, opts...); err == nil </span><span class="cov2" title="2">{
cleaned := strings.TrimSpace(stripCodeFences(out))
if cleaned != "" </span><span class="cov2" title="2">{
return cleaned
@@ -4457,7 +4914,7 @@ func exportName(name string) string <span class="cov2" title="2">{
}
</pre>
- <pre class="file" id="file25" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic.
+ <pre class="file" id="file26" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic.
package lsp
import (
@@ -4500,11 +4957,11 @@ func (s *Server) handleCompletion(req Request) <span class="cov1" title="1">{
// 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="2">{
+func extractTriggerInfo(p CompletionParams) (kind int, ch string) <span class="cov2" title="2">{
if p.Context == nil </span><span class="cov0" title="0">{
return 0, ""
}</span>
- <span class="cov3" title="2">var ctx struct {
+ <span class="cov2" title="2">var ctx struct {
TriggerKind int `json:"triggerKind"`
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
@@ -4514,12 +4971,12 @@ func extractTriggerInfo(p CompletionParams) (kind int, ch string) <span class="c
b, _ := json.Marshal(p.Context)
_ = json.Unmarshal(b, &amp;ctx)
}</span>
- <span class="cov3" title="2">return ctx.TriggerKind, ctx.TriggerCharacter</span>
+ <span class="cov2" title="2">return ctx.TriggerKind, ctx.TriggerCharacter</span>
}
// --- completion helpers ---
-func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov3" title="2">{
+func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov2" title="2">{
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>
@@ -4529,20 +4986,20 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below,
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) <span class="cov10" title="18">{
+func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) <span class="cov8" title="18">{
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
inlinePrompt := lineHasInlinePrompt(current)
- if !inlinePrompt &amp;&amp; !s.isTriggerEvent(p, current) </span><span class="cov7" title="8">{
+ if !inlinePrompt &amp;&amp; !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 []CompletionItem{}, true
}</span>
- <span class="cov8" title="10">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{
+ <span class="cov6" title="10">if s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{
return []CompletionItem{}, true
}</span>
- <span class="cov8" title="10">inParams := inParamList(current, p.Position.Character)
+ <span class="cov6" title="10">inParams := inParamList(current, p.Position.Character)
manualInvoke := parseManualInvoke(p.Context)
// Cache fast-path
@@ -4553,39 +5010,39 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true
}</span>
- <span class="cov7" title="9">if isBareDoubleOpen(current) || isBareDoubleOpen(below) </span><span class="cov1" title="1">{
+ <span class="cov6" title="9">if isBareDoubleOpen(current) || isBareDoubleOpen(below) </span><span class="cov1" title="1">{
logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase)
return []CompletionItem{}, true
}</span>
- <span class="cov7" title="8">if !inParams &amp;&amp; !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) </span><span class="cov0" title="0">{
+ <span class="cov6" title="8">if !inParams &amp;&amp; !s.prefixHeuristicAllows(inlinePrompt, current, p, 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 []CompletionItem{}, true
}</span>
// Provider-native path
- <span class="cov7" title="8">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok </span><span class="cov1" title="1">{
+ <span class="cov6" title="8">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok </span><span class="cov1" title="1">{
return items, true
}</span>
// Chat path
- <span class="cov7" title="7">messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx)
+ <span class="cov5" title="7">messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx)
// Counters and options
sentSize := 0
- for _, m := range messages </span><span class="cov9" title="14">{
+ for _, m := range messages </span><span class="cov7" title="14">{
sentSize += len(m.Content)
}</span>
- <span class="cov7" title="7">s.incSentCounters(sentSize)
+ <span class="cov5" title="7">s.incSentCounters(sentSize)
opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
if s.codingTemperature != nil </span><span class="cov0" title="0">{
opts = append(opts, llm.WithTemperature(*s.codingTemperature))
}</span>
// Debounce and throttle before making the LLM call
- <span class="cov7" title="7">s.waitForDebounce(ctx)
+ <span class="cov5" title="7">s.waitForDebounce(ctx)
if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{
return nil, false
}</span>
- <span class="cov7" title="7">logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel())
+ <span class="cov5" title="7">logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel())
text, err := s.llmClient.Chat(ctx, messages, opts...)
if err != nil </span><span class="cov0" title="0">{
@@ -4593,98 +5050,98 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
s.logLLMStats()
return nil, false
}</span>
- <span class="cov7" title="7">s.incRecvCounters(len(text))
+ <span class="cov5" title="7">s.incRecvCounters(len(text))
s.logLLMStats()
cleaned := s.postProcessCompletion(strings.TrimSpace(text), current[:p.Position.Character], current)
if cleaned == "" </span><span class="cov0" title="0">{
return nil, false
}</span>
- <span class="cov7" title="7">s.completionCachePut(key, cleaned)
+ <span class="cov5" title="7">s.completionCachePut(key, cleaned)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true</span>
}
// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion.
-func parseManualInvoke(ctx any) bool <span class="cov8" title="11">{
- if ctx == nil </span><span class="cov6" title="5">{
+func parseManualInvoke(ctx any) bool <span class="cov7" title="11">{
+ if ctx == nil </span><span class="cov5" title="5">{
return false
}</span>
- <span class="cov6" title="6">var c struct {
+ <span class="cov5" title="6">var c struct {
TriggerKind int `json:"triggerKind"`
}
- if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov6" title="6">{
+ if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov5" title="6">{
_ = json.Unmarshal(raw, &amp;c)
}</span> else<span class="cov0" title="0"> {
b, _ := json.Marshal(ctx)
_ = json.Unmarshal(b, &amp;c)
}</span>
- <span class="cov6" title="6">return c.TriggerKind == 1</span>
+ <span class="cov5" title="6">return c.TriggerKind == 1</span>
}
// shouldSuppressForChatTriggerEOL returns true when a chat trigger like "&gt;" follows ?, !, :, or ; at EOL.
-func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov9" title="15">{
+func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov7" title="15">{
t := strings.TrimRight(current, " \t")
- if s.chatSuffix == "" </span><span class="cov6" title="6">{
+ if s.chatSuffix == "" </span><span class="cov5" title="6">{
return false
}</span>
- <span class="cov7" title="9">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov5" title="4">{
+ <span class="cov6" title="9">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov4" title="4">{
if len(t) &lt; len(s.chatSuffix)+1 </span><span class="cov0" title="0">{
return false
}</span>
- <span class="cov5" title="4">prev := string(t[len(t)-len(s.chatSuffix)-1])
- for _, pf := range s.chatPrefixes </span><span class="cov8" title="10">{
- if prev == pf </span><span class="cov3" title="2">{
+ <span class="cov4" title="4">prev := string(t[len(t)-len(s.chatSuffix)-1])
+ for _, pf := range s.chatPrefixes </span><span class="cov6" title="10">{
+ 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="7">return false</span>
+ <span class="cov5" title="7">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="cov8" title="13">{
+func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="13">{
// 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 &gt; len(current) </span><span class="cov0" title="0">{
idx = len(current)
}</span>
- <span class="cov8" title="13">allowNoPrefix := inlinePrompt
- if idx &gt; 0 </span><span class="cov8" title="11">{
+ <span class="cov7" title="13">allowNoPrefix := inlinePrompt
+ if idx &gt; 0 </span><span class="cov7" title="11">{
ch := current[idx-1]
- if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov5" title="4">{
+ if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov4" title="4">{
allowNoPrefix = true
}</span>
}
- <span class="cov8" title="13">if allowNoPrefix </span><span class="cov6" title="6">{
+ <span class="cov7" title="13">if allowNoPrefix </span><span class="cov5" title="6">{
return true
}</span>
// Walk left over whitespace
- <span class="cov7" title="7">j := idx
- for j &gt; 0 </span><span class="cov8" title="13">{
+ <span class="cov5" title="7">j := idx
+ for j &gt; 0 </span><span class="cov7" title="13">{
c := current[j-1]
- if c == ' ' || c == '\t' </span><span class="cov7" title="7">{
+ if c == ' ' || c == '\t' </span><span class="cov5" title="7">{
j--
continue</span>
}
- <span class="cov6" title="6">break</span>
+ <span class="cov5" title="6">break</span>
}
- <span class="cov7" title="7">start := computeWordStart(current, j)
+ <span class="cov5" title="7">start := computeWordStart(current, j)
min := 1
- if manualInvoke &amp;&amp; s.manualInvokeMinPrefix &gt;= 0 </span><span class="cov6" title="5">{
+ if manualInvoke &amp;&amp; s.manualInvokeMinPrefix &gt;= 0 </span><span class="cov5" title="5">{
min = s.manualInvokeMinPrefix
}</span>
- <span class="cov7" title="7">return j-start &gt;= min</span>
+ <span class="cov5" title="7">return j-start &gt;= min</span>
}
// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
-func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov8" title="11">{
+func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov7" title="11">{
cc, ok := s.llmClient.(llm.CodeCompleter)
- if !ok </span><span class="cov6" title="6">{
+ if !ok </span><span class="cov5" title="6">{
return nil, false
}</span>
- <span class="cov6" title="5">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position)
+ <span class="cov5" title="5">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position)
path := strings.TrimPrefix(p.TextDocument.URI, "file://")
// Build provider-native prompt from template
prompt := renderTemplate(s.promptNativeCompletion, map[string]string{
@@ -4696,11 +5153,11 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
if s.codingTemperature != nil </span><span class="cov0" title="0">{
temp = *s.codingTemperature
}</span>
- <span class="cov6" title="5">prov := ""
- if s.llmClient != nil </span><span class="cov6" title="5">{
+ <span class="cov5" title="5">prov := ""
+ if s.llmClient != nil </span><span class="cov5" title="5">{
prov = s.llmClient.Name()
}</span>
- <span class="cov6" title="5">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path)
+ <span class="cov5" title="5">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path)
ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel2()
@@ -4709,21 +5166,27 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
if !s.waitForThrottle(ctx2) </span><span class="cov0" title="0">{
return nil, false
}</span>
- <span class="cov6" title="5">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp)
- if err == nil &amp;&amp; len(suggestions) &gt; 0 </span><span class="cov5" title="4">{
+ // Count approximate payload sizes: prompt+after sent; first suggestion received
+ <span class="cov5" title="5">sentBytes := len(prompt) + len(after)
+ suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp)
+ if err == nil &amp;&amp; len(suggestions) &gt; 0 </span><span class="cov4" title="4">{
+ // Update counters and heartbeat
+ s.incSentCounters(sentBytes)
+ s.incRecvCounters(len(suggestions[0]))
+ s.logLLMStats()
cleaned := strings.TrimSpace(suggestions[0])
- if cleaned != "" </span><span class="cov5" title="4">{
+ if cleaned != "" </span><span class="cov4" title="4">{
cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned)
- if cleaned != "" </span><span class="cov5" title="4">{
+ if cleaned != "" </span><span class="cov4" title="4">{
cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned)
}</span>
- <span class="cov5" title="4">if cleaned != "" &amp;&amp; hasDoubleOpenTrigger(current) </span><span class="cov1" title="1">{
+ <span class="cov4" title="4">if cleaned != "" &amp;&amp; hasDoubleOpenTrigger(current) </span><span class="cov1" title="1">{
indent := leadingIndent(current)
if indent != "" </span><span class="cov1" title="1">{
cleaned = applyIndent(indent, cleaned)
}</span>
}
- <span class="cov5" title="4">if strings.TrimSpace(cleaned) != "" </span><span class="cov5" title="4">{
+ <span class="cov4" title="4">if strings.TrimSpace(cleaned) != "" </span><span class="cov4" title="4">{
key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText)
s.completionCachePut(key, cleaned)
return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true
@@ -4731,35 +5194,38 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams,
}
} else<span class="cov1" title="1"> if err != nil </span><span class="cov1" title="1">{
logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err)
+ // Still emit a heartbeat for visibility, even on error
+ s.incSentCounters(sentBytes)
+ s.logLLMStats()
}</span>
<span class="cov1" title="1">return nil, false</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="cov8" title="13">{
+func (s *Server) waitForDebounce(ctx context.Context) <span class="cov10" title="34">{
d := s.completionDebounce
- if d &lt;= 0 </span><span class="cov8" title="11">{
+ if d &lt;= 0 </span><span class="cov9" title="32">{
return
}</span>
- <span class="cov3" title="2">for </span><span class="cov5" title="4">{
+ <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="cov5" title="4">since := time.Since(last)
- if since &gt;= d </span><span class="cov3" title="2">{
+ <span class="cov4" title="4">since := time.Since(last)
+ if since &gt;= d </span><span class="cov2" title="2">{
return
}</span>
- <span class="cov3" title="2">rem := d - since
+ <span class="cov2" title="2">rem := d - since
timer := time.NewTimer(rem)
select </span>{
case &lt;-ctx.Done():<span class="cov0" title="0">
timer.Stop()
return</span>
- case &lt;-timer.C:<span class="cov3" title="2"></span>
+ case &lt;-timer.C:<span class="cov2" title="2"></span>
// loop and re-evaluate in case input occurred during sleep
}
}
@@ -4767,17 +5233,17 @@ func (s *Server) waitForDebounce(ctx context.Context) <span class="cov8" title="
// 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="cov8" title="13">{
+func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov10" title="34">{
interval := s.throttleInterval
- if interval &lt;= 0 </span><span class="cov8" title="10">{
+ if interval &lt;= 0 </span><span class="cov9" title="31">{
return true
}</span>
- <span class="cov4" title="3">var wait time.Duration
- for </span><span class="cov6" title="5">{
+ <span class="cov3" title="3">var wait time.Duration
+ for </span><span class="cov5" title="5">{
s.mu.Lock()
next := s.lastLLMCall.Add(interval)
now := time.Now()
- if now.Before(next) </span><span class="cov3" title="2">{
+ if now.Before(next) </span><span class="cov2" title="2">{
wait = next.Sub(now)
s.mu.Unlock()
timer := time.NewTimer(wait)
@@ -4785,20 +5251,20 @@ func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov8" ti
case &lt;-ctx.Done():<span class="cov0" title="0">
timer.Stop()
return false</span>
- case &lt;-timer.C:<span class="cov3" title="2">
+ case &lt;-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="cov4" title="3">s.lastLLMCall = now
+ <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="cov8" title="13">{
+func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message <span class="cov7" title="13">{
// Vars for templates
vars := map[string]string{
"file": p.TextDocument.URI,
@@ -4810,14 +5276,14 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText
}
sys := s.promptCompSysGeneral
userTpl := s.promptCompUserGeneral
- if inParams </span><span class="cov3" title="2">{
+ if inParams </span><span class="cov2" title="2">{
sys = s.promptCompSysParams
userTpl = s.promptCompUserParams
}</span>
- <span class="cov8" title="13">if inlinePrompt &amp;&amp; strings.TrimSpace(s.promptCompSysInline) != "" </span><span class="cov1" title="1">{
+ <span class="cov7" title="13">if inlinePrompt &amp;&amp; strings.TrimSpace(s.promptCompSysInline) != "" </span><span class="cov1" title="1">{
sys = s.promptCompSysInline
}</span>
- <span class="cov8" title="13">user := renderTemplate(userTpl, vars)
+ <span class="cov7" title="13">user := renderTemplate(userTpl, vars)
messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
if hasExtra &amp;&amp; strings.TrimSpace(extraText) != "" </span><span class="cov1" title="1">{
extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText})
@@ -4826,33 +5292,33 @@ func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText
}</span>
<span class="cov1" title="1">messages = append(messages, llm.Message{Role: "user", Content: extra})</span>
}
- <span class="cov8" title="13">return messages</span>
+ <span class="cov7" title="13">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="cov8" title="10">{
+func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov6" title="10">{
cleaned := stripCodeFences(text)
if cleaned != "" &amp;&amp; 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="cov8" title="10">if cleaned != "" </span><span class="cov8" title="10">{
+ <span class="cov6" title="10">if cleaned != "" </span><span class="cov6" title="10">{
cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned)
}</span>
- <span class="cov8" title="10">if cleaned != "" </span><span class="cov8" title="10">{
+ <span class="cov6" title="10">if cleaned != "" </span><span class="cov6" title="10">{
cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned)
}</span>
- <span class="cov8" title="10">if cleaned != "" &amp;&amp; hasDoubleOpenTrigger(currentLine) </span><span class="cov1" title="1">{
+ <span class="cov6" title="10">if cleaned != "" &amp;&amp; hasDoubleOpenTrigger(currentLine) </span><span class="cov1" title="1">{
if indent := leadingIndent(currentLine); indent != "" </span><span class="cov1" title="1">{
cleaned = applyIndent(indent, cleaned)
}</span>
}
- <span class="cov8" title="10">return cleaned</span>
+ <span class="cov6" title="10">return cleaned</span>
}
</pre>
- <pre class="file" id="file26" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go.
+ <pre class="file" id="file27" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go.
package lsp
import (
@@ -5016,7 +5482,7 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov7" title="5">{
msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...)
opts := s.llmRequestOpts()
logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel())
- text, err := s.llmClient.Chat(ctx, msgs, opts...)
+ text, err := s.chatWithStats(ctx, msgs, opts...)
if err != nil </span><span class="cov0" title="0">{
logging.Logf("lsp ", "chat llm error: %v", err)
return
@@ -5181,7 +5647,7 @@ func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" tit
}
</pre>
- <pre class="file" id="file27" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test).
+ <pre class="file" id="file28" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test).
package lsp
import (
@@ -5217,7 +5683,7 @@ func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1">
}
</pre>
- <pre class="file" id="file28" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go.
+ <pre class="file" id="file29" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go.
package lsp
import (
@@ -5225,6 +5691,7 @@ import (
"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">{
@@ -5248,7 +5715,11 @@ func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{
func (s *Server) handleInitialized() <span class="cov1" title="1">{
logging.Logf("lsp ", "client initialized")
-}</span>
+ // Emit an initial tmux heartbeat with provider/model
+ if s.llmClient != nil </span><span class="cov0" title="0">{
+ _ = tmx.SetStatus(tmx.FormatLLMStartStatus(s.llmClient.Name(), s.llmClient.DefaultModel()))
+ }</span>
+}
func (s *Server) handleShutdown(req Request) <span class="cov1" title="1">{
s.reply(req.ID, nil, nil)
@@ -5260,17 +5731,18 @@ func (s *Server) handleExit() <span class="cov0" title="0">{
}</span>
</pre>
- <pre class="file" id="file29" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters).
+ <pre class="file" id="file30" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters).
package lsp
import (
- "strings"
- "time"
+ "context"
+ "strings"
+ "time"
- "codeberg.org/snonux/hexai/internal/llm"
- "codeberg.org/snonux/hexai/internal/logging"
- "codeberg.org/snonux/hexai/internal/textutil"
- tmx "codeberg.org/snonux/hexai/internal/tmux"
+ "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/logging"
+ "codeberg.org/snonux/hexai/internal/textutil"
+ tmx "codeberg.org/snonux/hexai/internal/tmux"
)
// Configurable inline trigger characters (default to '&gt;') used by free helpers below.
@@ -5281,53 +5753,56 @@ var (
)
// llmRequestOpts builds request options from server settings.
-func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov6" title="17">{
+func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov6" title="21">{
opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
if s.codingTemperature != nil </span><span class="cov0" title="0">{
opts = append(opts, llm.WithTemperature(*s.codingTemperature))
}</span>
- <span class="cov6" title="17">return opts</span>
+ <span class="cov6" title="21">return opts</span>
}
// small helpers for LLM traffic stats
-func (s *Server) incSentCounters(n int) <span class="cov5" title="8">{
+func (s *Server) incSentCounters(n int) <span class="cov7" title="34">{
s.mu.Lock()
s.llmReqTotal++
s.llmSentBytesTotal += int64(n)
s.mu.Unlock()
}</span>
-func (s *Server) incRecvCounters(n int) <span class="cov5" title="8">{
+func (s *Server) incRecvCounters(n int) <span class="cov7" title="32">{
s.mu.Lock()
s.llmRespTotal++
s.llmRespBytesTotal += int64(n)
s.mu.Unlock()
}</span>
-func (s *Server) logLLMStats() <span class="cov5" title="8">{
+func (s *Server) logLLMStats() <span class="cov7" title="34">{
s.mu.RLock()
avgSent := int64(0)
- if s.llmReqTotal &gt; 0 </span><span class="cov5" title="8">{
+ if s.llmReqTotal &gt; 0 </span><span class="cov7" title="34">{
avgSent = s.llmSentBytesTotal / s.llmReqTotal
}</span>
- <span class="cov5" title="8">avgRecv := int64(0)
- if s.llmRespTotal &gt; 0 </span><span class="cov5" title="8">{
+ <span class="cov7" title="34">avgRecv := int64(0)
+ if s.llmRespTotal &gt; 0 </span><span class="cov7" title="32">{
avgRecv = s.llmRespBytesTotal / s.llmRespTotal
}</span>
- <span class="cov5" title="8">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal
+ <span class="cov7" title="34">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal
s.mu.RUnlock()
mins := time.Since(s.startTime).Minutes()
if mins &lt;= 0 </span><span class="cov0" title="0">{
mins = 0.001
}</span>
- <span class="cov5" title="8">rpm := float64(reqs) / mins
+ <span class="cov7" title="34">rpm := float64(reqs) / mins
sentPerMin := float64(sentTot) / mins
recvPerMin := float64(recvTot) / mins
- logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin)
- // Best-effort tmux status update
- if s.llmClient != nil </span><span class="cov4" title="7">{
- _ = tmx.SetStatus("LLM:" + s.llmClient.DefaultModel())
- }</span>
+ logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin)
+ // Best-effort tmux status update with a compact stats heartbeat
+ if s.llmClient != nil </span><span class="cov7" title="33">{
+ model := s.llmClient.DefaultModel()
+ provider := s.llmClient.Name()
+ status := tmx.FormatLLMStatsStatusColored(provider, model, reqs, rpm, sentTot, recvTot)
+ _ = tmx.SetStatus(status)
+ }</span>
}
// Completion prompt builders and filters
@@ -5341,7 +5816,7 @@ func inParamList(current string, cursor int) bool <span class="cov6" title="13">
}
// renderTemplate performs simple {{var}} replacement in a template string.
-func renderTemplate(t string, vars map[string]string) string <span class="cov7" title="33">{ return textutil.RenderTemplate(t, vars) }</span>
+func renderTemplate(t string, vars map[string]string) string <span class="cov8" title="37">{ return textutil.RenderTemplate(t, vars) }</span>
func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="17">{
if inParams </span><span class="cov3" title="3">{
@@ -5389,6 +5864,30 @@ func isIdentChar(ch byte) bool <span class="cov7" title="26">{
return (ch &gt;= 'a' &amp;&amp; ch &lt;= 'z') || (ch &gt;= 'A' &amp;&amp; ch &lt;= 'Z') || (ch &gt;= '0' &amp;&amp; ch &lt;= '9') || ch == '_'
}</span>
+// chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat.
+func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) <span class="cov6" title="21">{
+ // Count bytes sent
+ sent := 0
+ for _, m := range msgs </span><span class="cov8" title="42">{
+ sent += len(m.Content)
+ }</span>
+ <span class="cov6" title="21">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="21">txt, err := s.llmClient.Chat(ctx, msgs, opts...)
+ if err != nil </span><span class="cov1" title="1">{
+ s.logLLMStats()
+ return "", err
+ }</span>
+ <span class="cov6" title="20">s.incRecvCounters(len(txt))
+ s.logLLMStats()
+ return txt, nil</span>
+}
+
// Inline prompt utilities
func lineHasInlinePrompt(line string) bool <span class="cov6" title="21">{
if _, _, _, ok := findStrictInlineTag(line); ok </span><span class="cov3" title="4">{
@@ -5434,12 +5933,12 @@ func applyIndent(indent, suggestion string) string <span class="cov3" title="4">
// findStrictInlineTag finds &gt;text&gt; (configurable), with no space after the first
// opening marker and no space immediately before the closing marker. Returns the
// text between markers, the start index, the end index just after closing, and ok.
-func findStrictInlineTag(line string) (string, int, int, bool) <span class="cov8" title="50">{
+func findStrictInlineTag(line string) (string, int, int, bool) <span class="cov8" title="52">{
pos := 0
- for pos &lt; len(line) </span><span class="cov9" title="65">{
+ for pos &lt; len(line) </span><span class="cov9" title="66">{
// find opening marker
j := strings.IndexByte(line[pos:], inlineOpenChar)
- if j &lt; 0 </span><span class="cov7" title="27">{
+ if j &lt; 0 </span><span class="cov7" title="28">{
return "", 0, 0, false
}</span>
<span class="cov8" title="38">j += pos
@@ -5466,7 +5965,7 @@ func findStrictInlineTag(line string) (string, int, int, bool) <span class="cov8
<span class="cov6" title="15">end := closeIdx + 1
return inner, j, end, true</span>
}
- <span class="cov4" title="7">return "", 0, 0, false</span>
+ <span class="cov5" title="8">return "", 0, 0, false</span>
}
// isBareDoubleSemicolon reports whether the line contains a standalone
@@ -5559,7 +6058,7 @@ func isIdentBoundary(ch byte) bool <span class="cov10" title="100">{
}</span>
// stripCodeFences removes surrounding Markdown code fences from a model response.
-func stripCodeFences(s string) string <span class="cov8" title="36">{ return textutil.StripCodeFences(s) }</span>
+func stripCodeFences(s string) string <span class="cov8" title="39">{ 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="cov5" title="11">{
@@ -5589,19 +6088,19 @@ func labelForCompletion(cleaned, filter string) string <span class="cov6" title=
}
// extractRangeText returns the exact text within the given document range.
-func extractRangeText(d *document, r Range) string <span class="cov3" title="4">{
- if r.Start.Line == r.End.Line </span><span class="cov3" title="3">{
+func extractRangeText(d *document, r Range) string <span class="cov4" title="6">{
+ if r.Start.Line == r.End.Line </span><span class="cov4" title="5">{
line := d.lines[r.Start.Line]
if r.Start.Character &lt; 0 </span><span class="cov0" title="0">{
r.Start.Character = 0
}</span>
- <span class="cov3" title="3">if r.End.Character &gt; len(line) </span><span class="cov0" title="0">{
+ <span class="cov4" title="5">if r.End.Character &gt; len(line) </span><span class="cov0" title="0">{
r.End.Character = len(line)
}</span>
- <span class="cov3" title="3">if r.Start.Character &gt; r.End.Character </span><span class="cov1" title="1">{
+ <span class="cov4" title="5">if r.Start.Character &gt; r.End.Character </span><span class="cov1" title="1">{
return ""
}</span>
- <span class="cov2" title="2">return line[r.Start.Character:r.End.Character]</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
@@ -5728,7 +6227,7 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="c
}
</pre>
- <pre class="file" id="file30" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats.
+ <pre class="file" id="file31" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats.
package lsp
import (
@@ -5808,11 +6307,14 @@ type Server struct {
promptDocumentSystem string
promptRewriteUser string
promptDiagnosticsUser string
- promptDocumentUser string
- promptGoTestSystem string
- promptGoTestUser string
- promptSimplifySystem string
- promptSimplifyUser string
+ promptDocumentUser string
+ promptGoTestSystem string
+ promptGoTestUser string
+ promptSimplifySystem string
+ promptSimplifyUser string
+
+ // Custom actions configured by user
+ customActions []CustomAction
}
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
@@ -5851,10 +6353,24 @@ type ServerOptions struct {
PromptRewriteUser string
PromptDiagnosticsUser string
PromptDocumentUser string
- PromptGoTestSystem string
- PromptGoTestUser string
- PromptSimplifySystem string
- PromptSimplifyUser string
+ PromptGoTestSystem string
+ PromptGoTestUser string
+ PromptSimplifySystem string
+ PromptSimplifyUser string
+
+ // Custom actions
+ CustomActions []CustomAction
+}
+
+// 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="cov10" title="7">{
@@ -5933,14 +6449,18 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions)
s.promptDocumentSystem = opts.PromptDocumentSystem
s.promptRewriteUser = opts.PromptRewriteUser
s.promptDiagnosticsUser = opts.PromptDiagnosticsUser
- s.promptDocumentUser = opts.PromptDocumentUser
- s.promptGoTestSystem = opts.PromptGoTestSystem
- s.promptGoTestUser = opts.PromptGoTestUser
- s.promptSimplifySystem = opts.PromptSimplifySystem
- s.promptSimplifyUser = opts.PromptSimplifyUser
+ s.promptDocumentUser = opts.PromptDocumentUser
+ s.promptGoTestSystem = opts.PromptGoTestSystem
+ s.promptGoTestUser = opts.PromptGoTestUser
+ s.promptSimplifySystem = opts.PromptSimplifySystem
+ s.promptSimplifyUser = opts.PromptSimplifyUser
+
+ if len(opts.CustomActions) &gt; 0 </span><span class="cov0" title="0">{
+ s.customActions = append([]CustomAction{}, opts.CustomActions...)
+ }</span>
// Assign package-level inline trigger chars for free helper functions
- if s.inlineOpen != "" </span><span class="cov10" title="7">{
+ <span class="cov10" title="7">if s.inlineOpen != "" </span><span class="cov10" title="7">{
inlineOpenChar = s.inlineOpen[0]
}</span>
<span class="cov10" title="7">if s.inlineClose != "" </span><span class="cov10" title="7">{
@@ -5995,7 +6515,7 @@ func (s *Server) Run() error <span class="cov1" title="1">{
}
</pre>
- <pre class="file" id="file31" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing.
+ <pre class="file" id="file32" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing.
package lsp
import (
@@ -6045,25 +6565,25 @@ func (s *Server) readMessage() ([]byte, error) <span class="cov3" title="2">{
<span class="cov1" title="1">return buf, nil</span>
}
-func (s *Server) writeMessage(v any) <span class="cov10" title="18">{
+func (s *Server) writeMessage(v any) <span class="cov10" title="20">{
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="18">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
+ <span class="cov10" title="20">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="18">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{
+ <span class="cov10" title="20">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="file32" style="display: none">package testutil
+ <pre class="file" id="file33" style="display: none">package testutil
// MultilineDocBlock returns a realistic multi-line documentation block.
func MultilineDocBlock() string <span class="cov8" title="1">{
@@ -6091,172 +6611,330 @@ func MalformedJSON() string <span class="cov8" title="1">{
}</span>
</pre>
- <pre class="file" id="file33" style="display: none">package textutil
+ <pre class="file" id="file34" style="display: none">package textutil
+
+import "fmt"
+
+// HumanBytes renders n in a short human-friendly form using base-1000 units.
+// Examples: 999 -&gt; 999B, 1200 -&gt; 1.2k, 1540000 -&gt; 1.5M
+func HumanBytes(n int64) string <span class="cov10" title="104">{
+ if n &lt; 1000 </span><span class="cov9" title="103">{
+ return fmt.Sprintf("%dB", n)
+ }</span>
+ <span class="cov1" title="1">const unit = 1000.0
+ v := float64(n)
+ suffix := []string{"k", "M", "G", "T"}
+ i := 0
+ for v &gt;= unit &amp;&amp; i &lt; len(suffix)-1 </span><span class="cov1" title="1">{
+ v /= unit
+ i++
+ }</span>
+ <span class="cov1" title="1">s := fmt.Sprintf("%.1f%s", v, suffix[i])
+ // Strip trailing ".0"
+ if len(s) &gt;= 3 &amp;&amp; s[len(s)-2:] == ".0" </span><span class="cov0" title="0">{
+ s = fmt.Sprintf("%d%s", int(v), suffix[i])
+ }</span>
+ <span class="cov1" title="1">return s</span>
+}
+</pre>
+
+ <pre class="file" id="file35" 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="46">{
- if t == "" || len(vars) == 0 </span><span class="cov5" title="11">{
- return t
- }</span>
- <span class="cov7" title="35">out := t
- for k, v := range vars </span><span class="cov9" title="95">{
- out = strings.ReplaceAll(out, "{{"+k+"}}", v)
- }</span>
- <span class="cov7" title="35">return out</span>
+func RenderTemplate(t string, vars map[string]string) string <span class="cov8" title="54">{
+ if t == "" || len(vars) == 0 </span><span class="cov5" title="11">{
+ return t
+ }</span>
+ <span class="cov8" title="43">out := t
+ for k, v := range vars </span><span class="cov9" title="111">{
+ out = strings.ReplaceAll(out, "{{"+k+"}}", v)
+ }</span>
+ <span class="cov8" title="43">return out</span>
}
// StripCodeFences removes surrounding Markdown triple-backtick fences.
-func StripCodeFences(s string) string <span class="cov8" title="52">{
- t := strings.TrimSpace(s)
- if t == "" </span><span class="cov0" title="0">{
- return t
- }</span>
- <span class="cov8" title="52">lines := strings.Split(t, "\n")
- start := 0
- for start &lt; len(lines) &amp;&amp; strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{
- start++
- }</span>
- <span class="cov8" title="52">end := len(lines) - 1
- for end &gt;= 0 &amp;&amp; strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{
- end--
- }</span>
- <span class="cov8" title="52">if start &gt;= len(lines) || end &lt; 0 || start &gt; end </span><span class="cov0" title="0">{
- return t
- }</span>
- <span class="cov8" title="52">first := strings.TrimSpace(lines[start])
- last := strings.TrimSpace(lines[end])
- if strings.HasPrefix(first, "```") &amp;&amp; last == "```" &amp;&amp; end &gt; start </span><span class="cov6" title="20">{
- inner := strings.Join(lines[start+1:end], "\n")
- return inner
- }</span>
- <span class="cov7" title="32">return t</span>
+func StripCodeFences(s string) string <span class="cov8" title="59">{
+ t := strings.TrimSpace(s)
+ if t == "" </span><span class="cov1" title="1">{
+ return t
+ }</span>
+ <span class="cov8" title="58">lines := strings.Split(t, "\n")
+ start := 0
+ for start &lt; len(lines) &amp;&amp; strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{
+ start++
+ }</span>
+ <span class="cov8" title="58">end := len(lines) - 1
+ for end &gt;= 0 &amp;&amp; strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{
+ end--
+ }</span>
+ <span class="cov8" title="58">if start &gt;= len(lines) || end &lt; 0 || start &gt; end </span><span class="cov0" title="0">{
+ return t
+ }</span>
+ <span class="cov8" title="58">first := strings.TrimSpace(lines[start])
+ last := strings.TrimSpace(lines[end])
+ if strings.HasPrefix(first, "```") &amp;&amp; last == "```" &amp;&amp; end &gt; start </span><span class="cov6" title="20">{
+ inner := strings.Join(lines[start+1:end], "\n")
+ return inner
+ }</span>
+ <span class="cov7" title="38">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;, /* */, &lt;!-- --&gt;, //, #, --.
func InstructionFromSelection(sel string) (string, string) <span class="cov6" title="14">{
- lines := strings.Split(sel, "\n")
- for idx, line := range lines </span><span class="cov6" title="14">{
- if instr, cleaned, ok := FindFirstInstructionInLine(line); ok &amp;&amp; strings.TrimSpace(instr) != "" </span><span class="cov6" title="14">{
- lines[idx] = cleaned
- return instr, strings.Join(lines, "\n")
- }</span>
- }
- <span class="cov0" title="0">return "", sel</span>
+ lines := strings.Split(sel, "\n")
+ for idx, line := range lines </span><span class="cov6" title="14">{
+ if instr, cleaned, ok := FindFirstInstructionInLine(line); ok &amp;&amp; strings.TrimSpace(instr) != "" </span><span class="cov6" title="14">{
+ 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="15">{
- type cand struct{ start, end int; text string }
- cands := []cand{}
- if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov4" title="5">{
- cands = append(cands, cand{start: l, end: r, text: t})
- }</span>
- <span class="cov6" title="15">if i := strings.Index(line, "/*"); i &gt;= 0 </span><span class="cov2" title="2">{
- if j := strings.Index(line[i+2:], "*/"); j &gt;= 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="cov6" title="15">if i := strings.Index(line, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
- if j := strings.Index(line[i+4:], "--&gt;"); j &gt;= 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="cov6" title="15">if i := strings.Index(line, "//"); i &gt;= 0 </span><span class="cov3" title="3">{
- cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
- }</span>
- <span class="cov6" title="15">if i := strings.Index(line, "#"); i &gt;= 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="cov6" title="15">if i := strings.Index(line, "--"); i &gt;= 0 </span><span class="cov3" title="4">{
- cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
- }</span>
- <span class="cov6" title="15">if len(cands) == 0 </span><span class="cov0" title="0">{ return "", line, false }</span>
- <span class="cov6" title="15">best := cands[0]
- for _, c := range cands[1:] </span><span class="cov3" title="3">{
- if c.start &gt;= 0 &amp;&amp; (best.start &lt; 0 || c.start &lt; best.start) </span><span class="cov0" title="0">{ best = c }</span>
- }
- <span class="cov6" title="15">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t")
- return best.text, cleaned, true</span>
+ type cand struct {
+ start, end int
+ text string
+ }
+ cands := []cand{}
+ if t, l, r, ok := FindStrictInlineTag(line); ok </span><span class="cov4" title="5">{
+ cands = append(cands, cand{start: l, end: r, text: t})
+ }</span>
+ <span class="cov6" title="15">if i := strings.Index(line, "/*"); i &gt;= 0 </span><span class="cov2" title="2">{
+ if j := strings.Index(line[i+2:], "*/"); j &gt;= 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="cov6" title="15">if i := strings.Index(line, "&lt;!--"); i &gt;= 0 </span><span class="cov2" title="2">{
+ if j := strings.Index(line[i+4:], "--&gt;"); j &gt;= 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="cov6" title="15">if i := strings.Index(line, "//"); i &gt;= 0 </span><span class="cov3" title="3">{
+ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
+ }</span>
+ <span class="cov6" title="15">if i := strings.Index(line, "#"); i &gt;= 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="cov6" title="15">if i := strings.Index(line, "--"); i &gt;= 0 </span><span class="cov3" title="4">{
+ cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])})
+ }</span>
+ <span class="cov6" title="15">if len(cands) == 0 </span><span class="cov0" title="0">{
+ return "", line, false
+ }</span>
+ <span class="cov6" title="15">best := cands[0]
+ for _, c := range cands[1:] </span><span class="cov3" title="3">{
+ if c.start &gt;= 0 &amp;&amp; (best.start &lt; 0 || c.start &lt; best.start) </span><span class="cov0" title="0">{
+ best = c
+ }</span>
+ }
+ <span class="cov6" title="15">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="17">{
- for i := 0; i &lt; len(line); i++ </span><span class="cov10" title="113">{
- if line[i] != ';' </span><span class="cov9" title="105">{ continue</span> }
- <span class="cov4" title="8">if i+1 &lt; len(line) &amp;&amp; line[i+1] == ' ' </span><span class="cov1" title="1">{ continue</span> }
- <span class="cov4" title="7">for j := i + 1; j &lt; len(line); j++ </span><span class="cov8" title="41">{
- if line[j] == ';' </span><span class="cov4" title="6">{
- if j-1 &gt;= 0 &amp;&amp; line[j-1] == ' ' </span><span class="cov0" title="0">{ continue</span> }
- <span class="cov4" title="6">inner := strings.TrimSpace(line[i+1 : j])
- if inner != "" </span><span class="cov4" title="6">{ return inner, i, j + 1, true }</span>
- }
+ for i := 0; i &lt; len(line); i++ </span><span class="cov10" title="113">{
+ if line[i] != ';' </span><span class="cov9" title="105">{
+ continue</span>
+ }
+ <span class="cov4" title="8">if i+1 &lt; len(line) &amp;&amp; line[i+1] == ' ' </span><span class="cov1" title="1">{
+ continue</span>
+ }
+ <span class="cov4" title="7">for j := i + 1; j &lt; len(line); j++ </span><span class="cov8" title="41">{
+ if line[j] == ';' </span><span class="cov4" title="6">{
+ if j-1 &gt;= 0 &amp;&amp; line[j-1] == ' ' </span><span class="cov0" title="0">{
+ continue</span>
+ }
+ <span class="cov4" title="6">inner := strings.TrimSpace(line[i+1 : j])
+ if inner != "" </span><span class="cov4" title="6">{
+ return inner, i, j + 1, true
+ }</span>
+ }
+ }
}
- }
- <span class="cov5" title="11">return "", -1, -1, false</span>
+ <span class="cov5" title="11">return "", -1, -1, false</span>
}
-
</pre>
- <pre class="file" id="file34" style="display: none">package tmux
+ <pre class="file" id="file36" style="display: none">package tmux
import (
- "os"
- "os/exec"
- "strings"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "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="cov10" title="25">{
- v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS"))
- if v == "" </span><span class="cov10" title="25">{ return true }</span>
- <span class="cov0" title="0">v = strings.ToLower(v)
- return v == "1" || v == "true" || v == "yes" || v == "on"</span>
+func Enabled() bool <span class="cov10" title="62">{
+ v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS"))
+ if v == "" </span><span class="cov10" title="62">{
+ return true
+ }</span>
+ <span class="cov0" title="0">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="cov10" title="25">{
- if !Enabled() || !HasBinary() || !InSession() </span><span class="cov0" title="0">{ return nil }</span>
- <span class="cov10" title="25">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="cov10" title="25">return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run()</span>
+func SetUserOption(key, value string) error <span class="cov10" title="62">{
+ if !Enabled() || !HasBinary() || !InSession() </span><span class="cov0" title="0">{
+ return nil
+ }</span>
+ <span class="cov10" title="62">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="cov10" title="62">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="cov10" title="25">{ return SetUserOption("hexai_status", value) }</span>
+func SetStatus(value string) error <span class="cov10" title="62">{ 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="cov0" title="0">{
+ 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="cov9" title="52">{
+ 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>
+// FormatLLMStartStatus renders a short colored heartbeat at start/initialize time.
+// Example: "LLM:openai:gpt-4.1 ⏳"
+func FormatLLMStartStatus(provider, model string) string <span class="cov6" title="10">{
+ 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="cov10" title="62">{
+ 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="cov10" title="62"> {
+ switch theme </span>{
+ case "white-on-purple", "purple", "magenta", "white-on-magenta":<span class="cov10" title="62">
+ 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="cov10" title="62">if baseFG == "" </span><span class="cov0" title="0">{ // no theme selected
+ baseFG = "default"
+ }</span>
+ }
+
+ // Theme-aware arrow styles
+ <span class="cov10" title="62">upStyle, downStyle := "#[fg=colour3]", "#[fg=colour2]" // defaults: yellow up, green down
+ if fg != "" || bg != "" </span><span class="cov10" title="62">{ // 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="cov10" title="62">if strings.Contains(s, baseFGToken) </span><span class="cov10" title="62">{
+ s = strings.ReplaceAll(s, baseFGToken, "#[fg="+baseFG+"]")
+ }</span>
+ <span class="cov10" title="62">if strings.Contains(s, arrowUpToken) </span><span class="cov9" title="52">{
+ s = strings.ReplaceAll(s, arrowUpToken, upStyle)
+ }</span>
+ <span class="cov10" title="62">if strings.Contains(s, arrowDownToken) </span><span class="cov9" title="52">{
+ s = strings.ReplaceAll(s, arrowDownToken, downStyle)
+ }</span>
+
+ <span class="cov10" title="62">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="cov10" title="62">prefix := "#[fg=" + baseFG
+ if bg != "" </span><span class="cov10" title="62">{
+ prefix += ",bg=" + bg
+ }</span>
+ <span class="cov10" title="62">prefix += "]"
+ return prefix + s + "#[fg=default,bg=default]"</span>
+}
</pre>
- <pre class="file" id="file35" style="display: none">package tmux
+ <pre class="file" id="file37" style="display: none">package tmux
import (
- "os"
- "os/exec"
- "strconv"
- "strings"
+ "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="cov2" title="2">{ return HasBinary() &amp;&amp; InSession() }</span>
// HasBinary reports whether the tmux binary is on PATH.
-var lookPath = exec.LookPath
-var command = exec.Command
+var (
+ lookPath = exec.LookPath
+ command = exec.Command
+)
-func HasBinary() bool <span class="cov10" title="29">{ _, err := lookPath("tmux"); return err == nil }</span>
+func HasBinary() bool <span class="cov10" title="66">{ _, 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="28">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span>
+func InSession() bool <span class="cov9" title="65">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span>
// SplitOpts controls how a new pane is created for running a command.
type SplitOpts struct {
@@ -6286,20 +6964,20 @@ func SplitRun(opts SplitOpts, argv []string) error <span class="cov1" title="1">
// tmux takes a single command string. Use a conservative shell join.
<span class="cov1" title="1">cmdStr := shellJoin(argv)
args = append(args, cmdStr)
- c := command("tmux", args...)
- return c.Run()</span>
+ 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="cov1" title="1">{
out := make([]string, 0, len(argv))
- for _, a := range argv </span><span class="cov4" title="4">{
+ for _, a := range argv </span><span class="cov3" title="4">{
if a == "" </span><span class="cov0" title="0">{
out = append(out, "''")
continue</span>
}
- <span class="cov4" title="4">if isSafeBare(a) </span><span class="cov2" title="2">{
+ <span class="cov3" title="4">if isSafeBare(a) </span><span class="cov2" title="2">{
out = append(out, a)
continue</span>
}
@@ -6312,10 +6990,10 @@ func shellJoin(argv []string) string <span class="cov1" title="1">{
}
// isSafeBare returns true if a contains only safe characters for bare words.
-func isSafeBare(s string) bool <span class="cov4" title="4">{
- for i := 0; i &lt; len(s); i++ </span><span class="cov9" title="27">{
+func isSafeBare(s string) bool <span class="cov3" title="4">{
+ for i := 0; i &lt; len(s); i++ </span><span class="cov8" title="27">{
b := s[i]
- if (b &gt;= 'a' &amp;&amp; b &lt;= 'z') || (b &gt;= 'A' &amp;&amp; b &lt;= 'Z') || (b &gt;= '0' &amp;&amp; b &lt;= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov9" title="25">{
+ if (b &gt;= 'a' &amp;&amp; b &lt;= 'z') || (b &gt;= 'A' &amp;&amp; b &lt;= 'Z') || (b &gt;= '0' &amp;&amp; b &lt;= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov7" title="25">{
continue</span>
}
<span class="cov2" title="2">return false</span>