From 9dc5fc419df4e5ef80594918742b5462d5a4ad4b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Feb 2026 23:23:57 +0200 Subject: coverage --- docs/coverage.html | 4795 +++++++++++++++++++++++++++++----------------------- 1 file changed, 2677 insertions(+), 2118 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 4526ad1..f0b0e08 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -61,7 +61,7 @@ - + @@ -79,67 +79,69 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + @@ -177,13 +179,13 @@ import ( "codeberg.org/snonux/hexai/internal/hexailsp" ) -func main() { +func main() { logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") defaultCfg := defaultConfigPath() configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultCfg)) showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() - if *showVersion { + if *showVersion { log.Println(internal.Version) return } @@ -194,12 +196,12 @@ func main() { } } -func defaultConfigPath() string { +func defaultConfigPath() string { path, err := appconfig.ConfigPath() if err != nil { return "$XDG_CONFIG_HOME/hexai/config.toml" } - return path + return path } @@ -268,34 +270,34 @@ import ( "codeberg.org/snonux/hexai/internal/hexaicli" ) -func main() { +func main() { configPath, remaining := splitConfigPath(os.Args[1:]) logger := log.New(io.Discard, "", 0) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) cliEntries := cfg.CLIConfigs - if len(cliEntries) == 0 { + if len(cliEntries) == 0 { cliEntries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} } - fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) defaultPath := defaultConfigPath() configFlag := fs.String("config", configPath, fmt.Sprintf("path to config file (default: %s)", defaultPath)) showVersion := fs.Bool("version", false, "print version and exit") selectedFlags := make([]bool, len(cliEntries)) - for i, entry := range cliEntries { + for i, entry := range cliEntries { name := strconv.Itoa(i) provider := strings.TrimSpace(entry.Provider) - if provider == "" { + if provider == "" { provider = cfg.Provider } - model := strings.TrimSpace(entry.Model) - if model == "" { + model := strings.TrimSpace(entry.Model) + if model == "" { model = pickDefaultModel(cfg, provider) } - desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) + desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) fs.BoolVar(&selectedFlags[i], name, false, desc) } - _ = fs.Parse(remaining) - if *showVersion { + _ = fs.Parse(remaining) + if *showVersion { fmt.Fprintln(os.Stdout, internal.Version) return } @@ -321,16 +323,16 @@ func main() { } } -func splitConfigPath(args []string) (string, []string) { +func splitConfigPath(args []string) (string, []string) { var path string rest := make([]string, 0, len(args)) skip := false - for i := 0; i < len(args); i++ { + for i := 0; i < len(args); i++ { if skip { skip = false continue } - arg := args[i] + arg := args[i] switch { case arg == "--config" || arg == "-config": if i+1 < len(args) { @@ -341,30 +343,30 @@ func splitConfigPath(args []string) (string, []string) path = arg[len("-config="):] - default: + default: rest = append(rest, arg) } } - return strings.TrimSpace(path), rest + return strings.TrimSpace(path), rest } -func pickDefaultModel(cfg appconfig.App, provider string) string { +func pickDefaultModel(cfg appconfig.App, provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { case "ollama": return strings.TrimSpace(cfg.OllamaModel) case "copilot": return strings.TrimSpace(cfg.CopilotModel) - default: + default: return strings.TrimSpace(cfg.OpenAIModel) } } -func defaultConfigPath() string { +func defaultConfigPath() string { cfgPath, err := appconfig.ConfigPath() if err != nil { return "$XDG_CONFIG_HOME/hexai/config.toml" } - return cfgPath + return cfgPath } @@ -397,6 +399,7 @@ type App struct { ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` + RequestTimeout int `json:"request_timeout" toml:"request_timeout"` // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"` // Minimum identifier characters required for manual (TriggerKind=1) invoke @@ -410,6 +413,10 @@ type App struct { // Completion throttle in milliseconds. When > 0, caps the minimum spacing // between LLM requests (both chat and code-completer paths). CompletionThrottleMs int `json:"completion_throttle_ms" toml:"completion_throttle_ms"` + // CompletionWaitAll controls whether to wait for all configured completion + // backends before returning results. When true (default), waits for all + // backends. When false, returns the first result immediately. + CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"` TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` Provider string `json:"provider" toml:"provider"` @@ -438,6 +445,10 @@ type App struct { CopilotModel string `json:"copilot_model" toml:"copilot_model"` // Default temperature for Copilot requests (nil means use provider default) CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` + AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"` + AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"` + // Default temperature for Anthropic requests (nil means use provider default) + AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"` // Per-surface provider/model configurations (ordered; first entry is primary) CompletionConfigs []SurfaceConfig `json:"-" toml:"-"` @@ -493,7 +504,7 @@ type CustomAction struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App { +func newDefaultConfig() App { // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -503,10 +514,12 @@ func newDefaultConfig() App { ContextWindowLines: 120, MaxContextTokens: 4000, LogPreviewLimit: 100, + RequestTimeout: 30, CodingTemperature: &t, OpenAITemperature: &t, OllamaTemperature: &t, CopilotTemperature: &t, + AnthropicTemperature: &t, ManualInvokeMinPrefix: 0, CompletionDebounceMs: 800, CompletionThrottleMs: 0, @@ -549,7 +562,7 @@ func newDefaultConfig() App { // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } +func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) } // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { @@ -559,35 +572,35 @@ type LoadOptions struct { } // LoadWithOptions reads configuration and applies the requested loading options. -func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { +func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { cfg := newDefaultConfig() - if logger == nil { + if logger == nil { return cfg // Return defaults if no logger is provided (e.g. in tests) } - configPath := strings.TrimSpace(opts.ConfigPath) + configPath := strings.TrimSpace(opts.ConfigPath) if configPath != "" { if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } else if err != nil { logger.Printf("cannot open config file %s: %v", configPath, err) } - } else { + } else { path, err := getConfigPath() if err != nil { logger.Printf("%v", err) - } else if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil { + } else if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) } } - if !opts.IgnoreEnv { + if !opts.IgnoreEnv { // Environment overrides (take precedence over file) - if envCfg := loadFromEnv(logger); envCfg != nil { + if envCfg := loadFromEnv(logger); envCfg != nil { cfg.mergeWith(envCfg) } } - return cfg + return cfg } // Private helpers @@ -605,6 +618,7 @@ type fileConfig struct { OpenRouter sectionOpenRouter `toml:"openrouter"` Copilot sectionCopilot `toml:"copilot"` Ollama sectionOllama `toml:"ollama"` + Anthropic sectionAnthropic `toml:"anthropic"` Prompts sectionPrompts `toml:"prompts"` Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` @@ -616,6 +630,7 @@ type sectionGeneral struct { ContextWindowLines int `toml:"context_window_lines"` MaxContextTokens int `toml:"max_context_tokens"` CodingTemperature *float64 `toml:"coding_temperature"` + RequestTimeout int `toml:"request_timeout"` } type sectionLogging struct { @@ -623,9 +638,10 @@ type sectionLogging struct { } type sectionCompletion struct { - CompletionDebounceMs int `toml:"completion_debounce_ms"` - CompletionThrottleMs int `toml:"completion_throttle_ms"` - ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` + CompletionDebounceMs int `toml:"completion_debounce_ms"` + CompletionThrottleMs int `toml:"completion_throttle_ms"` + ManualInvokeMinPrefix int `toml:"manual_invoke_min_prefix"` + CompletionWaitAll *bool `toml:"completion_wait_all"` } type sectionTriggers struct { @@ -657,19 +673,19 @@ type sectionOpenAI struct { Presets map[string]string `toml:"presets"` } -func (s sectionOpenAI) isZero() bool { +func (s sectionOpenAI) isZero() bool { return strings.TrimSpace(s.Model) == "" && strings.TrimSpace(s.BaseURL) == "" && s.Temperature == nil && len(s.Presets) == 0 } -func (s sectionOpenAI) resolvedModel() string { +func (s sectionOpenAI) resolvedModel() string { model := strings.TrimSpace(s.Model) if model == "" { return "" } - if len(s.Presets) == 0 { + if len(s.Presets) == 0 { return model } - if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" { + if mapped := strings.TrimSpace(s.Presets[model]); mapped != "" { return mapped } lower := strings.ToLower(model) @@ -701,6 +717,12 @@ type sectionOllama struct { Temperature *float64 `toml:"temperature"` } +type sectionAnthropic struct { + Model string `toml:"model"` + BaseURL string `toml:"base_url"` + Temperature *float64 `toml:"temperature"` +} + // Prompts sections type sectionPrompts struct { Completion sectionPromptsCompletion `toml:"completion"` @@ -761,63 +783,66 @@ type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } -func (fc *fileConfig) toApp() App { +func (fc *fileConfig) toApp() App { out := App{} // Merge section: general - if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil { + if (fc.General != sectionGeneral{}) || fc.General.CodingTemperature != nil { tmp := App{ MaxTokens: fc.General.MaxTokens, ContextMode: fc.General.ContextMode, ContextWindowLines: fc.General.ContextWindowLines, MaxContextTokens: fc.General.MaxContextTokens, CodingTemperature: fc.General.CodingTemperature, + RequestTimeout: fc.General.RequestTimeout, } out.mergeBasics(&tmp) } // logging - if (fc.Logging != sectionLogging{}) { + if (fc.Logging != sectionLogging{}) { tmp := App{LogPreviewLimit: fc.Logging.LogPreviewLimit} out.mergeBasics(&tmp) } // completion - if (fc.Completion != sectionCompletion{}) { + if fc.Completion.CompletionDebounceMs != 0 || fc.Completion.CompletionThrottleMs != 0 || + fc.Completion.ManualInvokeMinPrefix != 0 || fc.Completion.CompletionWaitAll != nil { tmp := App{ CompletionDebounceMs: fc.Completion.CompletionDebounceMs, CompletionThrottleMs: fc.Completion.CompletionThrottleMs, ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix, + CompletionWaitAll: fc.Completion.CompletionWaitAll, } out.mergeBasics(&tmp) } // triggers - if len(fc.Triggers.TriggerCharacters) > 0 { + if len(fc.Triggers.TriggerCharacters) > 0 { tmp := App{TriggerCharacters: fc.Triggers.TriggerCharacters} out.mergeBasics(&tmp) } // inline - if (fc.Inline != sectionInline{}) { + if (fc.Inline != sectionInline{}) { tmp := App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose} out.mergeBasics(&tmp) } // chat - if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { + if strings.TrimSpace(fc.Chat.ChatSuffix) != "" || len(fc.Chat.ChatPrefixes) > 0 { tmp := App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes} out.mergeBasics(&tmp) } // provider - if strings.TrimSpace(fc.Provider.Name) != "" { + if strings.TrimSpace(fc.Provider.Name) != "" { tmp := App{Provider: fc.Provider.Name} out.mergeBasics(&tmp) } // openai - if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { + if !fc.OpenAI.isZero() || fc.OpenAI.Temperature != nil { tmp := App{ OpenAIBaseURL: fc.OpenAI.BaseURL, OpenAIModel: fc.OpenAI.resolvedModel(), @@ -827,7 +852,7 @@ func (fc *fileConfig) toApp() App { } // openrouter - if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil { + if (fc.OpenRouter != sectionOpenRouter{}) || fc.OpenRouter.Temperature != nil { tmp := App{ OpenRouterBaseURL: fc.OpenRouter.BaseURL, OpenRouterModel: fc.OpenRouter.Model, @@ -837,7 +862,7 @@ func (fc *fileConfig) toApp() App { } // copilot - if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { + if (fc.Copilot != sectionCopilot{}) || fc.Copilot.Temperature != nil { tmp := App{ CopilotBaseURL: fc.Copilot.BaseURL, CopilotModel: fc.Copilot.Model, @@ -847,7 +872,7 @@ func (fc *fileConfig) toApp() App { } // ollama - if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { + if (fc.Ollama != sectionOllama{}) || fc.Ollama.Temperature != nil { tmp := App{ OllamaBaseURL: fc.Ollama.BaseURL, OllamaModel: fc.Ollama.Model, @@ -856,34 +881,44 @@ func (fc *fileConfig) toApp() App { out.mergeProviderFields(&tmp) } + // anthropic + if (fc.Anthropic != sectionAnthropic{}) || fc.Anthropic.Temperature != nil { + tmp := App{ + AnthropicBaseURL: fc.Anthropic.BaseURL, + AnthropicModel: fc.Anthropic.Model, + AnthropicTemperature: fc.Anthropic.Temperature, + } + out.mergeProviderFields(&tmp) + } + // prompts // completion - if (fc.Prompts.Completion != sectionPromptsCompletion{}) { - if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { + if (fc.Prompts.Completion != sectionPromptsCompletion{}) { + if strings.TrimSpace(fc.Prompts.Completion.SystemGeneral) != "" { out.PromptCompletionSystemGeneral = fc.Prompts.Completion.SystemGeneral } - if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" { + if strings.TrimSpace(fc.Prompts.Completion.SystemParams) != "" { out.PromptCompletionSystemParams = fc.Prompts.Completion.SystemParams } - if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" { + if strings.TrimSpace(fc.Prompts.Completion.SystemInline) != "" { out.PromptCompletionSystemInline = fc.Prompts.Completion.SystemInline } - if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" { + if strings.TrimSpace(fc.Prompts.Completion.UserGeneral) != "" { out.PromptCompletionUserGeneral = fc.Prompts.Completion.UserGeneral } - if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" { + if strings.TrimSpace(fc.Prompts.Completion.UserParams) != "" { out.PromptCompletionUserParams = fc.Prompts.Completion.UserParams } - if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" { + if strings.TrimSpace(fc.Prompts.Completion.ExtraHeader) != "" { out.PromptCompletionExtraHeader = fc.Prompts.Completion.ExtraHeader } } // chat - if strings.TrimSpace(fc.Prompts.Chat.System) != "" { + if strings.TrimSpace(fc.Prompts.Chat.System) != "" { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + 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) != "" || @@ -893,39 +928,39 @@ func (fc *fileConfig) toApp() App { strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || - len(fc.Prompts.CodeAction.Custom) > 0 { - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { + len(fc.Prompts.CodeAction.Custom) > 0 { + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" { out.PromptCodeActionDiagnosticsSystem = fc.Prompts.CodeAction.DiagnosticsSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" { out.PromptCodeActionDocumentSystem = fc.Prompts.CodeAction.DocumentSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" { out.PromptCodeActionRewriteUser = fc.Prompts.CodeAction.RewriteUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" { out.PromptCodeActionDiagnosticsUser = fc.Prompts.CodeAction.DiagnosticsUser } - if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" { out.PromptCodeActionDocumentUser = fc.Prompts.CodeAction.DocumentUser } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser } - if len(fc.Prompts.CodeAction.Custom) > 0 { - for _, ca := range fc.Prompts.CodeAction.Custom { + if len(fc.Prompts.CodeAction.Custom) > 0 { + for _, ca := range fc.Prompts.CodeAction.Custom { out.CustomActions = append(out.CustomActions, CustomAction{ ID: strings.TrimSpace(ca.ID), Title: strings.TrimSpace(ca.Title), @@ -940,55 +975,55 @@ func (fc *fileConfig) toApp() App { } } // cli - if (fc.Prompts.CLI != sectionPromptsCLI{}) { - if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { + if (fc.Prompts.CLI != sectionPromptsCLI{}) { + if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { out.PromptCLIDefaultSystem = fc.Prompts.CLI.DefaultSystem } - if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" { + if strings.TrimSpace(fc.Prompts.CLI.ExplainSystem) != "" { out.PromptCLIExplainSystem = fc.Prompts.CLI.ExplainSystem } } // provider-native - if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { + if strings.TrimSpace(fc.Prompts.ProviderNative.Completion) != "" { out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion } // tmux - if (fc.Tmux != sectionTmux{}) { + if (fc.Tmux != sectionTmux{}) { out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) } // stats - if fc.Stats.WindowMinutes > 0 { + if fc.Stats.WindowMinutes > 0 { out.StatsWindowMinutes = fc.Stats.WindowMinutes } - return out + return out } -func loadFromFile(path string, logger *log.Logger) (*App, error) { +func loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) - if err != nil { + if err != nil { if !os.IsNotExist(err) && logger != nil { logger.Printf("cannot open TOML config file %s: %v", path, err) } - return nil, err + return nil, err } - var tables fileConfig + var tables fileConfig errTables := toml.NewDecoder(strings.NewReader(string(b))).Decode(&tables) // Raw map for validation/presence checks var raw map[string]any _ = toml.Unmarshal(b, &raw) - if errTables != nil { - if logger != nil { + if errTables != nil { + if logger != nil { logger.Printf("invalid TOML config file %s: %v", path, errTables) } - return nil, errTables + return nil, errTables } // Reject legacy flat keys at top-level (sectioned-only config is allowed) - legacy := map[string]struct{}{ + 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": {}, @@ -997,27 +1032,27 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) { - if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { + for k := range raw { + if _, isTable := map[string]struct{}{"general": {}, "logging": {}, "completion": {}, "triggers": {}, "inline": {}, "chat": {}, "provider": {}, "models": {}, "openai": {}, "copilot": {}, "ollama": {}, "prompts": {}}[k]; isTable { continue } - if _, isLegacy := legacy[k]; isLegacy { + if _, isLegacy := legacy[k]; isLegacy { return nil, fmt.Errorf("unsupported flat key '%s' in config; use sectioned tables (see config.toml.example)", k) } } - if logger != nil { + if logger != nil { logger.Printf("loaded configuration from %s (TOML)", path) } // Merge order: flat first, then tables (so tables win over zero flat values) // Build App from tables only - tab := tables.toApp() + tab := tables.toApp() // Ensure explicit values from raw map are respected (defensive for ints) - if t, ok := raw["completion"].(map[string]any); ok { - if v, present := t["manual_invoke_min_prefix"]; present { + if t, ok := raw["completion"].(map[string]any); ok { + if v, present := t["manual_invoke_min_prefix"]; present { switch vv := v.(type) { - case int64: + case int64: tab.ManualInvokeMinPrefix = int(vv) case int: tab.ManualInvokeMinPrefix = vv @@ -1026,10 +1061,10 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if t, ok := raw["logging"].(map[string]any); ok { - if v, present := t["log_preview_limit"]; present { + if t, ok := raw["logging"].(map[string]any); ok { + if v, present := t["log_preview_limit"]; present { switch vv := v.(type) { - case int64: + case int64: tab.LogPreviewLimit = int(vv) case int: tab.LogPreviewLimit = vv @@ -1038,65 +1073,65 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) if m := parseSurfaceModels(raw, logger); m != nil { + if m := parseSurfaceModels(raw, logger); m != nil { tab.mergeSurfaceModels(m) } - return &tab, nil + return &tab, nil } -func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App { +func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App { modelsRaw, ok := raw["models"] - if !ok { + if !ok { return nil } - table, ok := modelsRaw.(map[string]any) + table, ok := modelsRaw.(map[string]any) if !ok { if logger != nil { logger.Printf("config: ignoring models section (expected table, got %T)", modelsRaw) } return nil } - var out App - appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool { + var out App + appendEntries := func(dest *[]SurfaceConfig, key string, val any) bool { entries, ok := parseSurfaceEntries(val, key, logger) - if !ok || len(entries) == 0 { + if !ok || len(entries) == 0 { return false } - *dest = append(*dest, entries...) + *dest = append(*dest, entries...) return true } - any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) - if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok { - if len(out.CodeActionConfigs) > 1 { - if logger != nil { + any := appendEntries(&out.CompletionConfigs, "models.completion", table["completion"]) + if ok := appendEntries(&out.CodeActionConfigs, "models.code_action", table["code_action"]); ok { + if len(out.CodeActionConfigs) > 1 { + if logger != nil { logger.Printf("config: models.code_action supports a single entry; ignoring %d extra", len(out.CodeActionConfigs)-1) } - out.CodeActionConfigs = out.CodeActionConfigs[:1] + out.CodeActionConfigs = out.CodeActionConfigs[:1] } - any = true + any = true } - any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any + any = appendEntries(&out.ChatConfigs, "models.chat", table["chat"]) || any any = appendEntries(&out.CLIConfigs, "models.cli", table["cli"]) || any if !any { return nil } - return &out + return &out } -func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) { +func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceConfig, bool) { switch v := raw.(type) { - case nil: + case nil: return nil, false - case []any: + case []any: var out []SurfaceConfig - for i, entry := range v { + for i, entry := range v { cfg, ok := decodeModelEntry(entry, fmt.Sprintf("%s[%d]", path, i), logger) if !ok || cfg == nil { continue } - out = append(out, *cfg) + out = append(out, *cfg) } - return out, len(out) > 0 + return out, len(out) > 0 default: if cfg, ok := decodeModelEntry(v, path, logger); ok && cfg != nil { return []SurfaceConfig{*cfg}, true @@ -1105,30 +1140,30 @@ func parseSurfaceEntries(raw any, path string, logger *log.Logger) ([]SurfaceCon } } -func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig { +func cloneSurfaceConfigs(src []SurfaceConfig) []SurfaceConfig { if len(src) == 0 { return nil } - out := make([]SurfaceConfig, len(src)) + out := make([]SurfaceConfig, len(src)) copy(out, src) return out } -func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) { +func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, bool) { if raw == nil { return nil, false } - switch v := raw.(type) { + switch v := raw.(type) { case string: model := strings.TrimSpace(v) if model == "" { return nil, false } return &SurfaceConfig{Model: model}, true - case map[string]any: + case map[string]any: model := "" provider := "" - if m, ok := v["model"]; ok { + if m, ok := v["model"]; ok { s, ok := m.(string) if !ok { if logger != nil { @@ -1136,9 +1171,9 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, } return nil, false } - model = strings.TrimSpace(s) + model = strings.TrimSpace(s) } - if pRaw, ok := v["provider"]; ok { + if pRaw, ok := v["provider"]; ok { ps, ok := pRaw.(string) if !ok { if logger != nil { @@ -1146,20 +1181,20 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, } return nil, false } - provider = strings.TrimSpace(ps) + provider = strings.TrimSpace(ps) } - var tempPtr *float64 - if tRaw, ok := v["temperature"]; ok { + var tempPtr *float64 + if tRaw, ok := v["temperature"]; ok { parsed, ok := parseTemperatureValue(tRaw, path, logger) if !ok { return nil, false } - tempPtr = parsed + tempPtr = parsed } - if model == "" && tempPtr == nil && provider == "" { + if model == "" && tempPtr == nil && provider == "" { return nil, false } - return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true + return &SurfaceConfig{Provider: provider, Model: model, Temperature: tempPtr}, true default: if logger != nil { logger.Printf("config: %s must be a string or table, got %T", path, raw) @@ -1168,9 +1203,9 @@ func decodeModelEntry(raw any, path string, logger *log.Logger) (*SurfaceConfig, } } -func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) { +func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, bool) { switch v := raw.(type) { - case float64: + case float64: return floatPtr(v), true case int64: return floatPtr(float64(v)), true @@ -1195,12 +1230,12 @@ func parseTemperatureValue(raw any, path string, logger *log.Logger) (*float64, } } -func floatPtr(v float64) *float64 { +func floatPtr(v float64) *float64 { f := v return &f } -func (a *App) mergeWith(other *App) { +func (a *App) mergeWith(other *App) { a.mergeBasics(other) a.mergeProviderFields(other) a.mergeSurfaceModels(other) @@ -1208,360 +1243,370 @@ func (a *App) mergeWith(other *App) { } // mergeBasics merges general (non-provider) fields. -func (a *App) mergeBasics(other *App) { - if other.MaxTokens > 0 { +func (a *App) mergeBasics(other *App) { + if other.MaxTokens > 0 { a.MaxTokens = other.MaxTokens } - if s := strings.TrimSpace(other.ContextMode); s != "" { + if s := strings.TrimSpace(other.ContextMode); s != "" { a.ContextMode = s } - if other.ContextWindowLines > 0 { + if other.ContextWindowLines > 0 { a.ContextWindowLines = other.ContextWindowLines } - if other.MaxContextTokens > 0 { + if other.MaxContextTokens > 0 { a.MaxContextTokens = other.MaxContextTokens } - if other.LogPreviewLimit >= 0 { + if other.LogPreviewLimit >= 0 { a.LogPreviewLimit = other.LogPreviewLimit } - if other.CodingTemperature != nil { // allow explicit 0.0 + if other.RequestTimeout > 0 { + a.RequestTimeout = other.RequestTimeout + } + if other.CodingTemperature != nil { // allow explicit 0.0 a.CodingTemperature = other.CodingTemperature } - if other.ManualInvokeMinPrefix >= 0 { + if other.ManualInvokeMinPrefix >= 0 { a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix } - if other.CompletionDebounceMs > 0 { + if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } - if other.CompletionThrottleMs > 0 { + if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { + if other.CompletionWaitAll != nil { + a.CompletionWaitAll = other.CompletionWaitAll + } + if len(other.TriggerCharacters) > 0 { a.TriggerCharacters = slices.Clone(other.TriggerCharacters) } - if s := strings.TrimSpace(other.InlineOpen); s != "" { + if s := strings.TrimSpace(other.InlineOpen); s != "" { a.InlineOpen = s } - if s := strings.TrimSpace(other.InlineClose); s != "" { + if s := strings.TrimSpace(other.InlineClose); s != "" { a.InlineClose = s } - if s := strings.TrimSpace(other.ChatSuffix); s != "" { + if s := strings.TrimSpace(other.ChatSuffix); s != "" { a.ChatSuffix = s } - if len(other.ChatPrefixes) > 0 { + if len(other.ChatPrefixes) > 0 { a.ChatPrefixes = slices.Clone(other.ChatPrefixes) } - if s := strings.TrimSpace(other.Provider); s != "" { + if s := strings.TrimSpace(other.Provider); s != "" { a.Provider = s } } // mergeSurfaceModels copies per-surface model and temperature overrides. -func (a *App) mergeSurfaceModels(other *App) { - if len(other.CompletionConfigs) > 0 { +func (a *App) mergeSurfaceModels(other *App) { + if len(other.CompletionConfigs) > 0 { a.CompletionConfigs = cloneSurfaceConfigs(other.CompletionConfigs) } - if len(other.CodeActionConfigs) > 0 { + if len(other.CodeActionConfigs) > 0 { a.CodeActionConfigs = cloneSurfaceConfigs(other.CodeActionConfigs) } - if len(other.ChatConfigs) > 0 { + if len(other.ChatConfigs) > 0 { a.ChatConfigs = cloneSurfaceConfigs(other.ChatConfigs) } - if len(other.CLIConfigs) > 0 { + if len(other.CLIConfigs) > 0 { a.CLIConfigs = cloneSurfaceConfigs(other.CLIConfigs) } } // mergePrompts copies non-empty prompt templates from other. -func (a *App) mergePrompts(other *App) { +func (a *App) mergePrompts(other *App) { // Completion - if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { + if strings.TrimSpace(other.PromptCompletionSystemGeneral) != "" { a.PromptCompletionSystemGeneral = other.PromptCompletionSystemGeneral } - if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { + if strings.TrimSpace(other.PromptCompletionSystemParams) != "" { a.PromptCompletionSystemParams = other.PromptCompletionSystemParams } - if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { + if strings.TrimSpace(other.PromptCompletionSystemInline) != "" { a.PromptCompletionSystemInline = other.PromptCompletionSystemInline } - if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { + if strings.TrimSpace(other.PromptCompletionUserGeneral) != "" { a.PromptCompletionUserGeneral = other.PromptCompletionUserGeneral } - if strings.TrimSpace(other.PromptCompletionUserParams) != "" { + if strings.TrimSpace(other.PromptCompletionUserParams) != "" { a.PromptCompletionUserParams = other.PromptCompletionUserParams } - if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { + if strings.TrimSpace(other.PromptCompletionExtraHeader) != "" { a.PromptCompletionExtraHeader = other.PromptCompletionExtraHeader } // Provider-native - if strings.TrimSpace(other.PromptNativeCompletion) != "" { + if strings.TrimSpace(other.PromptNativeCompletion) != "" { a.PromptNativeCompletion = other.PromptNativeCompletion } // Chat - if strings.TrimSpace(other.PromptChatSystem) != "" { + if strings.TrimSpace(other.PromptChatSystem) != "" { a.PromptChatSystem = other.PromptChatSystem } // Code actions - if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteSystem) != "" { a.PromptCodeActionRewriteSystem = other.PromptCodeActionRewriteSystem } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsSystem) != "" { a.PromptCodeActionDiagnosticsSystem = other.PromptCodeActionDiagnosticsSystem } - if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentSystem) != "" { a.PromptCodeActionDocumentSystem = other.PromptCodeActionDocumentSystem } - if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { + if strings.TrimSpace(other.PromptCodeActionRewriteUser) != "" { a.PromptCodeActionRewriteUser = other.PromptCodeActionRewriteUser } - if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDiagnosticsUser) != "" { a.PromptCodeActionDiagnosticsUser = other.PromptCodeActionDiagnosticsUser } - if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { + if strings.TrimSpace(other.PromptCodeActionDocumentUser) != "" { a.PromptCodeActionDocumentUser = other.PromptCodeActionDocumentUser } - if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser } // CLI - if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { + if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem } - if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { + if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { a.PromptCLIExplainSystem = other.PromptCLIExplainSystem } // Custom actions - if len(other.CustomActions) > 0 { + if len(other.CustomActions) > 0 { a.CustomActions = append([]CustomAction{}, other.CustomActions...) } - if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { + if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey } } // Validate checks custom actions and tmux settings for duplicates and consistency. -func (a App) Validate() error { +func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) seenHK := make(map[string]struct{}) - for _, ca := range a.CustomActions { + for _, ca := range a.CustomActions { id := strings.ToLower(strings.TrimSpace(ca.ID)) - if id == "" { + if id == "" { return fmt.Errorf("config: custom action missing required field id") } - if _, ok := seenID[id]; ok { + if _, ok := seenID[id]; ok { return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) } - seenID[id] = struct{}{} + seenID[id] = struct{}{} if strings.TrimSpace(ca.Title) == "" { return fmt.Errorf("config: custom action %s missing required field title", ca.ID) } // Validate scope - scope := strings.TrimSpace(ca.Scope) - if scope != "" && scope != "selection" && scope != "diagnostics" { + scope := strings.TrimSpace(ca.Scope) + if scope != "" && scope != "selection" && scope != "diagnostics" { return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) } // Instruction vs user - hasInstr := strings.TrimSpace(ca.Instruction) != "" + hasInstr := strings.TrimSpace(ca.Instruction) != "" hasUser := strings.TrimSpace(ca.User) != "" if hasInstr && hasUser { return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) } - if !hasInstr && !hasUser { + if !hasInstr && !hasUser { return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) } // Hotkey unique (case-insensitive), one rune if provided - if hk := strings.TrimSpace(ca.Hotkey); hk != "" { - if []rune(hk) == nil || len([]rune(hk)) != 1 { + if hk := strings.TrimSpace(ca.Hotkey); hk != "" { + if []rune(hk) == nil || len([]rune(hk)) != 1 { return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) } - lhk := strings.ToLower(hk) - if _, ok := seenHK[lhk]; ok { + lhk := strings.ToLower(hk) + if _, ok := seenHK[lhk]; ok { return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) } - seenHK[lhk] = struct{}{} + seenHK[lhk] = struct{}{} } } // Tmux custom menu hotkey validation - if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { + if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { if len([]rune(hk)) != 1 { return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) } // built-in hotkeys in tmux TUI: r,i,c,t,p,s - switch strings.ToLower(hk) { - case "r", "i", "c", "t", "p", "s": + switch strings.ToLower(hk) { + case "r", "i", "c", "t", "p", "s": return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk) } } - return nil + return nil } // mergeProviderFields merges per-provider configuration. -func (a *App) mergeProviderFields(other *App) { - if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { +func (a *App) mergeProviderFields(other *App) { + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { a.OpenAIBaseURL = s } - if s := strings.TrimSpace(other.OpenAIModel); s != "" { + if s := strings.TrimSpace(other.OpenAIModel); s != "" { a.OpenAIModel = s } - if other.OpenAITemperature != nil { // allow explicit 0.0 + if other.OpenAITemperature != nil { // allow explicit 0.0 a.OpenAITemperature = other.OpenAITemperature } - if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" { + if s := strings.TrimSpace(other.OpenRouterBaseURL); s != "" { a.OpenRouterBaseURL = s } - if s := strings.TrimSpace(other.OpenRouterModel); s != "" { + if s := strings.TrimSpace(other.OpenRouterModel); s != "" { a.OpenRouterModel = s } - if other.OpenRouterTemperature != nil { // allow explicit 0.0 + if other.OpenRouterTemperature != nil { // allow explicit 0.0 a.OpenRouterTemperature = other.OpenRouterTemperature } - if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { a.OllamaBaseURL = s } - if s := strings.TrimSpace(other.OllamaModel); s != "" { + if s := strings.TrimSpace(other.OllamaModel); s != "" { a.OllamaModel = s } - if other.OllamaTemperature != nil { // allow explicit 0.0 + if other.OllamaTemperature != nil { // allow explicit 0.0 a.OllamaTemperature = other.OllamaTemperature } - if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { a.CopilotBaseURL = s } - if s := strings.TrimSpace(other.CopilotModel); s != "" { + if s := strings.TrimSpace(other.CopilotModel); s != "" { a.CopilotModel = s } - if other.CopilotTemperature != nil { // allow explicit 0.0 + if other.CopilotTemperature != nil { // allow explicit 0.0 a.CopilotTemperature = other.CopilotTemperature } } -func getConfigPath() (string, error) { +func getConfigPath() (string, error) { return ConfigPath() } // ConfigPath returns the default config file path ($XDG_CONFIG_HOME/hexai/config.toml or ~/.config/hexai/config.toml). -func ConfigPath() (string, error) { +func ConfigPath() (string, error) { var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - } else { + } else { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("cannot find user home directory: %v", err) } - configPath = filepath.Join(home, ".config", "hexai", "config.toml") + configPath = filepath.Join(home, ".config", "hexai", "config.toml") } - return configPath, nil + return configPath, nil } // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. -func loadFromEnv(logger *log.Logger) *App { +func loadFromEnv(logger *log.Logger) *App { var out App var any bool // helpers - getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } - parseInt := func(k string) (int, bool) { + getenv := func(k string) string { return strings.TrimSpace(os.Getenv(k)) } + parseInt := func(k string) (int, bool) { v := getenv(k) - if v == "" { + if v == "" { return 0, false } - n, err := strconv.Atoi(v) + n, err := strconv.Atoi(v) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } return 0, false } - return n, true + return n, true } - parseFloatPtr := func(k string) (*float64, bool) { + parseFloatPtr := func(k string) (*float64, bool) { v := getenv(k) - if v == "" { + if v == "" { return nil, false } - f, err := strconv.ParseFloat(v, 64) + f, err := strconv.ParseFloat(v, 64) if err != nil { if logger != nil { logger.Printf("invalid %s: %v", k, err) } return nil, false } - return &f, true + return &f, true } - if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok { out.MaxTokens = n any = true } - if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { + if s := getenv("HEXAI_CONTEXT_MODE"); s != "" { out.ContextMode = s any = true } - if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { + if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok { out.ContextWindowLines = n any = true } - if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { + if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok { out.MaxContextTokens = n any = true } - if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { + if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok { out.LogPreviewLimit = n any = true } - if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { + if n, ok := parseInt("HEXAI_REQUEST_TIMEOUT"); ok { + out.RequestTimeout = n + any = true + } + if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok { out.ManualInvokeMinPrefix = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok { out.CompletionDebounceMs = n any = true } - if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { + if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok { out.CompletionThrottleMs = n any = true } - if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok { out.CodingTemperature = f any = true } - if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { + if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" { parts := strings.Split(s, ",") out.TriggerCharacters = nil - for _, p := range parts { - if t := strings.TrimSpace(p); t != "" { + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { out.TriggerCharacters = append(out.TriggerCharacters, t) } } - any = true + any = true } - if s := getenv("HEXAI_INLINE_OPEN"); s != "" { + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s any = true } - if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s any = true } - if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s any = true } - if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts { @@ -1571,132 +1616,145 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } - if s := getenv("HEXAI_PROVIDER"); s != "" { + if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s any = true } - modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) + modelForce := strings.TrimSpace(getenv("HEXAI_MODEL_FORCE")) modelGeneric := strings.TrimSpace(getenv("HEXAI_MODEL")) providerLower := strings.ToLower(strings.TrimSpace(out.Provider)) forceUsed := false genericUsed := false - pickModel := func(providerName, specific string) (string, bool) { + pickModel := func(providerName, specific string) (string, bool) { specific = strings.TrimSpace(specific) nameLower := strings.ToLower(strings.TrimSpace(providerName)) - if modelForce != "" { - if providerLower == nameLower { + if modelForce != "" { + if providerLower == nameLower { forceUsed = true return modelForce, true } - if providerLower == "" && !forceUsed { + if providerLower == "" && !forceUsed { forceUsed = true return modelForce, true } } - if specific != "" { + if specific != "" { return specific, true } - if modelGeneric != "" { - if providerLower == nameLower { + if modelGeneric != "" { + if providerLower == nameLower { return modelGeneric, true } - if providerLower == "" && !genericUsed { + if providerLower == "" && !genericUsed { genericUsed = true return modelGeneric, true } } - return "", false + return "", false } // Provider-specific - if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { + if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" { out.OpenAIBaseURL = s any = true } - if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { + if model, ok := pickModel("openai", getenv("HEXAI_OPENAI_MODEL")); ok { out.OpenAIModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok { out.OpenAITemperature = f any = true } - if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" { + if s := getenv("HEXAI_OPENROUTER_BASE_URL"); s != "" { out.OpenRouterBaseURL = s any = true } - if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok { + if model, ok := pickModel("openrouter", getenv("HEXAI_OPENROUTER_MODEL")); ok { out.OpenRouterModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OPENROUTER_TEMPERATURE"); ok { out.OpenRouterTemperature = f any = true } - if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { + if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" { out.OllamaBaseURL = s any = true } - if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { + if model, ok := pickModel("ollama", getenv("HEXAI_OLLAMA_MODEL")); ok { out.OllamaModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok { out.OllamaTemperature = f any = true } - if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { + if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" { out.CopilotBaseURL = s any = true } - if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { + if model, ok := pickModel("copilot", getenv("HEXAI_COPILOT_MODEL")); ok { out.CopilotModel = model any = true } - if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { + if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok { out.CopilotTemperature = f any = true } + if s := getenv("HEXAI_ANTHROPIC_BASE_URL"); s != "" { + out.AnthropicBaseURL = s + any = true + } + if model, ok := pickModel("anthropic", getenv("HEXAI_ANTHROPIC_MODEL")); ok { + out.AnthropicModel = model + any = true + } + if f, ok := parseFloatPtr("HEXAI_ANTHROPIC_TEMPERATURE"); ok { + out.AnthropicTemperature = f + any = true + } + // Per-surface overrides - buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) { + buildEntry := func(modelKey, tempKey, providerKey string) ([]SurfaceConfig, bool) { model := getenv(modelKey) tempPtr, tempSet := parseFloatPtr(tempKey) provider := getenv(providerKey) - if model == "" && provider == "" && !tempSet { + if model == "" && provider == "" && !tempSet { return nil, false } - entry := SurfaceConfig{Provider: provider, Model: model} - if tempSet { + entry := SurfaceConfig{Provider: provider, Model: model} + if tempSet { entry.Temperature = tempPtr } - return []SurfaceConfig{entry}, true + return []SurfaceConfig{entry}, true } - if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok { + if entries, ok := buildEntry("HEXAI_MODEL_COMPLETION", "HEXAI_TEMPERATURE_COMPLETION", "HEXAI_PROVIDER_COMPLETION"); ok { out.CompletionConfigs = entries any = true } - if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok { + if entries, ok := buildEntry("HEXAI_MODEL_CODE_ACTION", "HEXAI_TEMPERATURE_CODE_ACTION", "HEXAI_PROVIDER_CODE_ACTION"); ok { out.CodeActionConfigs = entries any = true } - if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok { + if entries, ok := buildEntry("HEXAI_MODEL_CHAT", "HEXAI_TEMPERATURE_CHAT", "HEXAI_PROVIDER_CHAT"); ok { out.ChatConfigs = entries any = true } - if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok { + if entries, ok := buildEntry("HEXAI_MODEL_CLI", "HEXAI_TEMPERATURE_CLI", "HEXAI_PROVIDER_CLI"); ok { out.CLIConfigs = entries any = true } - if !any { + if !any { return nil } - return &out + return &out } @@ -1711,15 +1769,15 @@ import ( ) // Resolve returns the editor command from HEXAI_EDITOR or EDITOR. -func Resolve() (string, error) { +func Resolve() (string, error) { ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) - if ed == "" { + if ed == "" { ed = strings.TrimSpace(os.Getenv("EDITOR")) } - if ed == "" { + if ed == "" { return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") } - return ed, nil + return ed, nil } // RunEditor is the seam that invokes the editor on the given file path. @@ -1735,40 +1793,40 @@ var RunEditor = func(editor, path string) error { // 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) { +func OpenTempAndEdit(initial []byte) (string, error) { ed, err := Resolve() if err != nil { return "", err } // Create temp file under system temp dir; ensure .md suffix - dir := os.TempDir() + dir := os.TempDir() f, err := os.CreateTemp(dir, "hexai-*.md") if err != nil { return "", err } - path := f.Name() - defer func() { _ = os.Remove(path) }() - if len(initial) > 0 { + path := f.Name() + defer func() { _ = os.Remove(path) }() + if len(initial) > 0 { if _, err := f.Write(initial); err != nil { _ = f.Close() return "", err } } - if err := f.Sync(); err != nil { + if err := f.Sync(); err != nil { _ = f.Close() return "", err } - if err := f.Close(); err != nil { + if err := f.Close(); err != nil { return "", err } - if err := RunEditor(ed, path); err != nil { + if err := RunEditor(ed, path); err != nil { return "", err } - b, err := os.ReadFile(filepath.Clean(path)) + b, err := os.ReadFile(filepath.Clean(path)) if err != nil { return "", err } - return strings.TrimSpace(string(b)), nil + return strings.TrimSpace(string(b)), nil } @@ -1798,12 +1856,12 @@ type Options struct { // 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 { - if opts.UIChild { +func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error { + if opts.UIChild { return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr) } // Always use tmux path - return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) + return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent) } // seams for unit tests @@ -1815,144 +1873,144 @@ var ( ) // openIO returns readers/writers for infile/outfile flags with deferred closers. -func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { +func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) { in := io.Reader(os.Stdin) out := io.Writer(os.Stdout) closeIn := func() {} - closeOut := func() {} - if path := infile; path != "" { + closeOut := func() {} + if path := infile; path != "" { f, err := os.Open(path) if err != nil { return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) } - in = f - closeIn = func() { _ = f.Close() } + in = f + closeIn = func() { _ = f.Close() } } - if path := outfile; path != "" { + if path := outfile; path != "" { f, err := os.Create(path) if err != nil { return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) } - out = f - closeOut = func() { _ = f.Close() } + out = f + closeOut = func() { _ = f.Close() } } - return in, out, closeIn, closeOut, nil + return in, out, closeIn, closeOut, nil } // 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 { - if outfile == "" { +func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error { + if outfile == "" { // No atomic handoff needed; just run normally to provided stdout var in io.Reader = os.Stdin - if infile != "" { + if infile != "" { f, err := os.Open(infile) if err != nil { return err } - defer func() { _ = f.Close() }() - in = f + defer func() { _ = f.Close() }() + in = f } - return runFn(ctx, in, stdout, stderr) + return runFn(ctx, in, stdout, stderr) } - tmp := outfile + ".tmp" + tmp := outfile + ".tmp" in, out, closeIn, closeOut, err := openIO(infile, tmp) if err != nil { return err } - defer closeIn() + defer closeIn() if err := runFn(ctx, in, out, stderr); err != nil { closeOut() if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil { return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr) } - } else { + } else { closeOut() } - return os.Rename(tmp, outfile) + return os.Rename(tmp, outfile) } -func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error { +func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error { dir, err := os.MkdirTemp("", "hexai-tmux-action-") if err != nil { return err } - defer func() { _ = os.RemoveAll(dir) }() - inPath := filepath.Join(dir, "input.txt") + defer func() { _ = os.RemoveAll(dir) }() + inPath := filepath.Join(dir, "input.txt") outPath := filepath.Join(dir, "reply.txt") if err := persistStdin(inPath, stdin); err != nil { return err } - exe, err := osExecutableFn() - if err != nil { + exe, err := osExecutableFn() + if err != nil { return err } - argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + 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 { + if err := splitRunFn(opts, argv); err != nil { return err } - if err := waitForFile(outPath, 60*time.Second); err != nil { + if err := waitForFile(outPath, 60*time.Second); err != nil { return err } - return catFileTo(stdout, outPath) + return catFileTo(stdout, outPath) } -func persistStdin(path string, stdin io.Reader) error { +func persistStdin(path string, stdin io.Reader) error { f, err := os.Create(path) if err != nil { return err } - defer func() { _ = f.Close() }() - if _, err := io.Copy(f, stdin); err != nil { + defer func() { _ = f.Close() }() + if _, err := io.Copy(f, stdin); err != nil { return err } - return f.Sync() + return f.Sync() } -func waitForFile(path string, timeout time.Duration) error { +func waitForFile(path string, timeout time.Duration) error { deadline := time.Now().Add(timeout) - for { - if _, err := os.Stat(path); err == nil { + for { + if _, err := os.Stat(path); err == nil { return nil } - if time.Now().After(deadline) { + if time.Now().After(deadline) { return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file") } - time.Sleep(200 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } } -func catFileTo(w io.Writer, path string) error { +func catFileTo(w io.Writer, path string) error { f, err := os.Open(path) if err != nil { return err } - defer func() { _ = f.Close() }() - _, err = io.Copy(w, f) + defer func() { _ = f.Close() }() + _, err = io.Copy(w, f) return err } // 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 { - var in io.Reader = stdin - var out io.Writer = stdout - if infile != "" { +func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error { + in := stdin + out := stdout + if infile != "" { f, err := os.Open(infile) if err != nil { return err } - defer func() { _ = f.Close() }() - in = f + defer func() { _ = f.Close() }() + in = f } - if outfile != "" { + if outfile != "" { f, err := os.Create(outfile) if err != nil { return err } - defer func() { _ = f.Close() }() - out = f + defer func() { _ = f.Close() }() + out = f } - _, err := io.Copy(out, in) + _, err := io.Copy(out, in) return err } @@ -1976,47 +2034,47 @@ import ( // <rest is selection/code> // // If the header is absent, the entire input is treated as selection. -func ParseInput(r io.Reader) (InputParts, error) { +func ParseInput(r io.Reader) (InputParts, error) { b, err := io.ReadAll(bufio.NewReader(r)) if err != nil { return InputParts{}, err } - raw := strings.TrimSpace(string(b)) + raw := strings.TrimSpace(string(b)) if raw == "" { return InputParts{Selection: ""}, nil } - lines := strings.Split(raw, "\n") + lines := strings.Split(raw, "\n") // find a case-insensitive line equal to "diagnostics:" diagsIdx := -1 - for i, ln := range lines { + for i, ln := range lines { t := strings.TrimSpace(strings.ToLower(ln)) - if t == "diagnostics:" { + if t == "diagnostics:" { diagsIdx = i break } } - if diagsIdx < 0 { + if diagsIdx < 0 { return InputParts{Selection: raw}, nil } // collect diagnostics until a blank line or EOF - diags := []string{} + diags := []string{} i := diagsIdx + 1 - for ; i < len(lines); i++ { + for ; i < len(lines); i++ { t := strings.TrimSpace(lines[i]) - if t == "" { + if t == "" { i++ break } - diags = append(diags, t) + diags = append(diags, t) } - sel := strings.Join(lines[i:], "\n") + sel := strings.Join(lines[i:], "\n") sel = strings.TrimSpace(sel) return InputParts{Selection: sel, Diagnostics: diags}, nil } // ExtractInstruction mirrors the LSP instructionFromSelection behavior (subset), // scanning the first line for an instruction marker and removing it from the selection. -func ExtractInstruction(sel string) (string, string) { return textutil.InstructionFromSelection(sel) } +func ExtractInstruction(sel string) (string, string) { return textutil.InstructionFromSelection(sel) } // findFirstInstructionInLine follows the same precedence as LSP: // - ;text; (strict) @@ -2043,10 +2101,10 @@ import ( ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } +func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } // StripFences removes surrounding markdown code fences. -func StripFences(s string) string { return textutil.StripCodeFences(s) } +func StripFences(s string) string { return textutil.StripCodeFences(s) } type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) @@ -2060,204 +2118,204 @@ type requestArgs struct { options []llm.RequestOption } -func providerOf(c any) string { - if n, ok := c.(providerNamer); ok { +func providerOf(c any) string { + if n, ok := c.(providerNamer); ok { return n.Name() } - return "llm" + return "llm" } -func canonicalProvider(name string) string { +func canonicalProvider(name string) string { p := strings.ToLower(strings.TrimSpace(name)) - if p == "" { + if p == "" { return "openai" } - return p + return p } -func defaultModelForProvider(cfg appconfig.App, provider string) string { +func defaultModelForProvider(cfg appconfig.App, provider string) string { switch provider { case "ollama": return cfg.OllamaModel case "copilot": return cfg.CopilotModel - default: + default: return cfg.OpenAIModel } } -func selectActionTemperature(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) { - if entry.Temperature != nil { +func selectActionTemperature(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) { + if entry.Temperature != nil { return *entry.Temperature, true } - if cfg.CodingTemperature != nil { + if cfg.CodingTemperature != nil { temp := *cfg.CodingTemperature - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 { + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 { temp = 1.0 } - return temp, true + return temp, true } - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") { + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") { return 1.0, true } - return 0, false + return 0, false } -func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) { +func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) { var b strings.Builder - for i, d := range diags { + for i, d := range diags { if strings.TrimSpace(d) == "" { continue } - b.WriteString(strings.TrimSpace(d)) - if i < len(diags)-1 { + b.WriteString(strings.TrimSpace(d)) + if i < len(diags)-1 { b.WriteString("\n") } } - sys := cfg.PromptCodeActionDiagnosticsSystem + sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { +func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionDocumentSystem user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { +func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) { +func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) { sys := cfg.PromptCodeActionGoTestSystem user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } -func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) { +func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) { // If user template is provided, prefer it and optional system - if strings.TrimSpace(ca.User) != "" { + if strings.TrimSpace(ca.User) != "" { sys := cfg.PromptCodeActionRewriteSystem if strings.TrimSpace(ca.System) != "" { sys = ca.System } // Currently only selection is available in tmux path; diagnostics list not wired - user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) + user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } // Else, use fixed instruction through rewrite template - return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection) + return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection) } -func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { +func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs) if err != nil { return "", err } - out := strings.TrimSpace(StripFences(txt)) + out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(out) + recv := len(out) _ = stats.Update(ctx, providerOf(client), client.DefaultModel(), sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil { + if snap, err := stats.TakeSnapshot(); err == nil { minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeReqs := int64(0) - if pe, ok := snap.Providers[providerOf(client)]; ok { - if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { + scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok { + if mc, ok2 := pe.Models[client.DefaultModel()]; ok2 { scopeReqs = mc.Reqs } } - scopeRPM := float64(scopeReqs) / minsWin + scopeRPM := float64(scopeReqs) / minsWin _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), client.DefaultModel(), scopeRPM, scopeReqs, snap.Window)) } - return out, nil + return out, nil } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) { +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, req requestArgs) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs, req.options...) if err != nil { return "", err } - out := strings.TrimSpace(StripFences(txt)) + out := strings.TrimSpace(StripFences(txt)) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(out) + recv := len(out) model := strings.TrimSpace(req.model) - if model == "" { + if model == "" { model = client.DefaultModel() } - _ = stats.Update(ctx, providerOf(client), model, sent, recv) - if snap, err := stats.TakeSnapshot(); err == nil { + _ = stats.Update(ctx, providerOf(client), model, sent, recv) + if snap, err := stats.TakeSnapshot(); err == nil { minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeReqs := int64(0) - if pe, ok := snap.Providers[providerOf(client)]; ok { - if mc, ok2 := pe.Models[model]; ok2 { + scopeReqs := int64(0) + if pe, ok := snap.Providers[providerOf(client)]; ok { + if mc, ok2 := pe.Models[model]; ok2 { scopeReqs = mc.Reqs } } - scopeRPM := float64(scopeReqs) / minsWin + scopeRPM := float64(scopeReqs) / minsWin _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, providerOf(client), model, scopeRPM, scopeReqs, snap.Window)) } - return out, nil + return out, nil } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) requestArgs { +func reqOptsFrom(cfg appconfig.App) requestArgs { opts := make([]llm.RequestOption, 0, 3) - if cfg.MaxTokens > 0 { + if cfg.MaxTokens > 0 { opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) } - provider := canonicalProvider(cfg.Provider) + provider := canonicalProvider(cfg.Provider) entries := cfg.CodeActionConfigs - if len(entries) == 0 { + if len(entries) == 0 { entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider, Model: strings.TrimSpace(defaultModelForProvider(cfg, provider))}} } - primary := entries[0] - if strings.TrimSpace(primary.Provider) != "" { + primary := entries[0] + if strings.TrimSpace(primary.Provider) != "" { provider = canonicalProvider(primary.Provider) } - model := strings.TrimSpace(primary.Model) - if model == "" { + model := strings.TrimSpace(primary.Model) + if model == "" { model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) } - if strings.TrimSpace(primary.Model) != "" { + if strings.TrimSpace(primary.Model) != "" { opts = append(opts, llm.WithModel(strings.TrimSpace(primary.Model))) } - if temp, ok := selectActionTemperature(cfg, provider, primary, model); ok { + if temp, ok := selectActionTemperature(cfg, provider, primary, model); ok { opts = append(opts, llm.WithTemperature(temp)) } - return requestArgs{model: model, options: opts} + return requestArgs{model: model, options: opts} } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 20*time.Second) } -func timeout8s(parent context.Context) (context.Context, context.CancelFunc) { +func timeout8s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 18*time.Second) } @@ -2293,53 +2351,53 @@ type configPathKey struct{} // to the executor. Cleared after use. var selectedCustom *appconfig.CustomAction -func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { +func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) - if cfg.StatsWindowMinutes > 0 { + if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } - if err := cfg.Validate(); err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) + if err := cfg.Validate(); err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err) return err } // Enable custom action submenu with configurable hotkey - if len(cfg.CustomActions) > 0 { + if len(cfg.CustomActions) > 0 { chooseActionFn = func() (ActionKind, error) { return RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey) } } - if len(cfg.CodeActionConfigs) > 0 { + if len(cfg.CodeActionConfigs) > 0 { if provider := strings.TrimSpace(cfg.CodeActionConfigs[0].Provider); provider != "" { cfg.Provider = provider } } - cli, err := newClientFromApp(cfg) - if err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) + cli, err := newClientFromApp(cfg) + if err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } - primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) - if primaryModel == "" { + primaryModel := strings.TrimSpace(reqOptsFrom(cfg).model) + if primaryModel == "" { primaryModel = cli.DefaultModel() } - _ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) + _ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), primaryModel)) var client chatDoer = cli parts, err := ParseInput(stdin) if err != nil { - 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 } - if strings.TrimSpace(parts.Selection) == "" { + if strings.TrimSpace(parts.Selection) == "" { return fmt.Errorf("hexai-tmux-action: no input provided on stdin") } - kind, err := chooseActionFn() + kind, err := chooseActionFn() if err != nil { return err } - out, err := executeAction(ctx, kind, parts, cfg, client, stderr) + out, err := executeAction(ctx, kind, parts, cfg, client, stderr) if err != nil { return err } - io.WriteString(stdout, out) + _, _ = io.WriteString(stdout, out) return nil } @@ -2351,97 +2409,97 @@ func WithConfigPath(ctx context.Context, path string) context.Context return context.WithValue(ctx, configPathKey{}, strings.TrimSpace(path)) } -func configPathFromContext(ctx context.Context) string { +func configPathFromContext(ctx context.Context) string { if ctx == nil { return "" } - if v, ok := ctx.Value(configPathKey{}).(string); ok { + if v, ok := ctx.Value(configPathKey{}).(string); ok { return strings.TrimSpace(v) } - return "" + return "" } -func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { +func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { switch kind { - case ActionSkip: + case ActionSkip: return parts.Selection, nil - case ActionRewrite: + case ActionRewrite: return handleRewriteAction(ctx, parts, cfg, client, stderr) case ActionDiagnostics: return handleDiagnosticsAction(ctx, parts, cfg, client) - case ActionDocument: + case ActionDocument: return handleDocumentAction(ctx, parts, cfg, client) - case ActionGoTest: + case ActionGoTest: return handleGoTestAction(ctx, parts, cfg, client) case ActionSimplify: return handleSimplifyAction(ctx, parts, cfg, client) - case ActionCustom: + case ActionCustom: return handleCustomAction(ctx, parts, cfg, client) - case ActionCustomPrompt: + case ActionCustomPrompt: return handleCustomPromptAction(ctx, parts, cfg, client, stderr) default: return parts.Selection, nil } } -func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { +func handleRewriteAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { instr, cleaned := ExtractInstruction(parts.Selection) if strings.TrimSpace(instr) == "" { - 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 } - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runRewrite(cctx, cfg, client, instr, cleaned) }) } -func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { +func handleDiagnosticsAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) }) } -func handleDocumentAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { +func handleDocumentAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runDocument(cctx, cfg, client, parts.Selection) }) } -func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) { +func handleGoTestAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout8s, func(cctx context.Context) (string, error) { return runGoTest(cctx, cfg, client, parts.Selection) }) } -func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { +func handleSimplifyAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runSimplify(cctx, cfg, client, parts.Selection) }) } -func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { +func handleCustomAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer) (string, error) { if selectedCustom == nil { return parts.Selection, nil } - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { out, err := runCustom(cctx, cfg, client, *selectedCustom, parts) selectedCustom = nil return out, err }) } -func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { +func handleCustomPromptAction(ctx context.Context, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { prompt, err := editor.OpenTempAndEdit(nil) if err != nil || strings.TrimSpace(prompt) == "" { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) return parts.Selection, nil } - return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { + return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) { return runRewrite(cctx, cfg, client, prompt, parts.Selection) }) } -func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) { +func runWithTimeout(ctx context.Context, timeout func(context.Context) (context.Context, context.CancelFunc), fn func(context.Context) (string, error)) (string, error) { innerCtx, cancel := timeout(ctx) defer cancel() return fn(innerCtx) @@ -2467,9 +2525,9 @@ type item struct { hotkey rune } -func (i item) Title() string { return i.title } -func (i item) Description() string { return i.desc } -func (i item) FilterValue() string { return i.title } +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.desc } +func (i item) FilterValue() string { return i.title } type model struct { list list.Model @@ -2477,7 +2535,7 @@ type model struct { done bool } -func newModel() model { +func newModel() model { items := []list.Item{ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'}, @@ -2494,25 +2552,25 @@ func newModel() model { return model{list: l} } -func (m model) Init() tea.Cmd { return nil } +func (m model) Init() tea.Cmd { return nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return handleKey(m, msg) - case tea.WindowSizeMsg: + case tea.WindowSizeMsg: m.list.SetSize(msg.Width, msg.Height) } - var cmd tea.Cmd + var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } -func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { raw := msg.String() low := strings.ToLower(raw) switch low { - case "esc", "q": + case "esc", "q": // Treat ESC and q as Skip/quit m.chosen = ActionSkip m.done = true @@ -2527,16 +2585,16 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) case "k", "up": m.list.CursorUp() - case "g", "home": + case "g", "home": m.list.Select(0) case "end": if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) } - case "s", "r", "c", "t", "i", "p": + case "s", "r", "c", "t", "i", "p": items := m.list.Items() - for i := 0; i < len(items); i++ { - if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low { + for i := 0; i < len(items); i++ { + if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low { m.list.Select(i) m.chosen = it.kind m.done = true @@ -2544,19 +2602,19 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) } } - if raw == "G" { // Shift+G jumps to end - if n := len(m.list.Items()); n > 0 { + if raw == "G" { // Shift+G jumps to end + if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) } } - return m, nil + return m, nil } -func (m model) View() string { +func (m model) View() string { if m.done { return "" } - return m.list.View() + return m.list.View() } // RunTUI returns the chosen ActionKind. @@ -2590,18 +2648,18 @@ import ( // 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) { +func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, error) { // When no customs, fall back to default menu if len(customs) == 0 { return RunTUI() } // Build main menu with an extra entry - hk := 'a' - if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 { + hk := 'a' + if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 { hk = r } // Create a model with default items plus Custom actions… - m := newModel() + m := newModel() items := m.list.Items() items = append(items, item{title: "Custom actions…", desc: "", kind: ActionCustom, hotkey: hk}) m.list.SetItems(items) @@ -2611,33 +2669,33 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti if err != nil { return ActionSkip, err } - if mm, ok := md.(model); ok { + if mm, ok := md.(model); ok { // If user chose built-in items (including Custom prompt), return immediately. if mm.chosen != ActionCustom { return mm.chosen, nil } } // Custom submenu: list each action; select one maps to ActionCustom and sets global - sub := newModel() + sub := newModel() subItems := make([]list.Item, 0, len(customs)) - for _, ca := range customs { + for _, ca := range customs { r := rune(0) - if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 { + if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 { r = rr } - subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r}) + subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r}) } - sub.list.SetItems(subItems) + sub.list.SetItems(subItems) sp := teaNewProgram(sub) smd, err := sp.Run() if err != nil { return ActionSkip, err } - if sm, ok := smd.(model); ok { - if it, ok := sm.list.SelectedItem().(item); ok { + if sm, ok := smd.(model); ok { + if it, ok := sm.list.SelectedItem().(item); ok { // Map by title - for i := range customs { - if customs[i].Title == it.title { + for i := range customs { + if customs[i].Title == it.title { c := customs[i] selectedCustom = &c return ActionCustom, nil @@ -2674,21 +2732,21 @@ var ( cursorStyle = lipgloss.NewStyle().Bold(true) ) -func (oneLineDelegate) Height() int { return 1 } -func (oneLineDelegate) Spacing() int { return 0 } -func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } -func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { +func (oneLineDelegate) Height() int { return 1 } +func (oneLineDelegate) Spacing() int { return 0 } +func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil } +func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { title := listItem.FilterValue() hk := '?' - if it, ok := listItem.(item); ok { + if it, ok := listItem.(item); ok { hk = it.hotkey } - hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) + hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) cursor := " " - if index == m.Index() { + if index == m.Index() { cursor = cursorStyle.Render("> ") } - fmt.Fprintf(w, "%s%s%s", cursor, title, hot) + _, _ = fmt.Fprintf(w, "%s%s%s", cursor, title, hot) } @@ -2751,28 +2809,28 @@ type ( configPathContextKey struct{} ) -func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) { +func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) { entries := cfg.CLIConfigs - if len(entries) == 0 { + if len(entries) == 0 { entries = []appconfig.SurfaceConfig{{}} } - jobs := make([]cliJob, 0, len(entries)) - for i, raw := range entries { + jobs := make([]cliJob, 0, len(entries)) + for i, raw := range entries { entry := appconfig.SurfaceConfig{Provider: strings.TrimSpace(raw.Provider), Model: strings.TrimSpace(raw.Model), Temperature: raw.Temperature} provider := entry.Provider - if provider == "" { + if provider == "" { provider = cfg.Provider } - provider = canonicalProvider(provider) + provider = canonicalProvider(provider) derived := cfg derived.Provider = provider switch provider { - case "openai": - if entry.Model != "" { + case "openai": + if entry.Model != "" { derived.OpenAIModel = entry.Model } - case "copilot": - if entry.Model != "" { + case "copilot": + if entry.Model != "" { derived.CopilotModel = entry.Model } case "ollama": @@ -2780,65 +2838,65 @@ func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) } - client, err := newClientFromApp(derived) - if err != nil { + client, err := newClientFromApp(derived) + if err != nil { return nil, err } - req := buildCLIRequest(entry, provider, cfg, client) + req := buildCLIRequest(entry, provider, cfg, client) if strings.TrimSpace(req.model) == "" { req.model = strings.TrimSpace(client.DefaultModel()) } - jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, client: client, req: req}) + jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, client: client, req: req}) } - return jobs, nil + return jobs, nil } -func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs { +func buildCLIRequest(entry appconfig.SurfaceConfig, provider string, cfg appconfig.App, client llm.Client) requestArgs { opts := make([]llm.RequestOption, 0, 2) - if cfg.MaxTokens > 0 { + if cfg.MaxTokens > 0 { opts = append(opts, llm.WithMaxTokens(cfg.MaxTokens)) } - model := strings.TrimSpace(entry.Model) - if model == "" { - if client != nil { + model := strings.TrimSpace(entry.Model) + if model == "" { + if client != nil { model = strings.TrimSpace(client.DefaultModel()) } - if model == "" { + if model == "" { model = strings.TrimSpace(defaultModelForProvider(cfg, provider)) } } - if entry.Model != "" { + if entry.Model != "" { opts = append(opts, llm.WithModel(entry.Model)) } - if temp, ok := cliTemperatureFromEntry(cfg, provider, entry, model); ok { + if temp, ok := cliTemperatureFromEntry(cfg, provider, entry, model); ok { opts = append(opts, llm.WithTemperature(temp)) } - return requestArgs{model: model, options: opts} + return requestArgs{model: model, options: opts} } -func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) { - if entry.Temperature != nil { +func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) { + if entry.Temperature != nil { return *entry.Temperature, true } - if cfg.CodingTemperature != nil { + if cfg.CodingTemperature != nil { temp := *cfg.CodingTemperature - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 { + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") && temp == 0.2 { temp = 1.0 } - return temp, true + return temp, true } - if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") { + if provider == "openai" && strings.HasPrefix(strings.ToLower(model), "gpt-5") { return 1.0, true } - return 0, false + return 0, false } -func canonicalProvider(name string) string { +func canonicalProvider(name string) string { p := strings.ToLower(strings.TrimSpace(name)) - if p == "" { + if p == "" { return "openai" } - return p + return p } func defaultModelForProvider(cfg appconfig.App, provider string) string { @@ -2854,63 +2912,63 @@ func defaultModelForProvider(cfg appconfig.App, provider string) string { +func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) configPath := configPathFromContext(ctx) cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) - if cfg.StatsWindowMinutes > 0 { + if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } - jobs, err := buildCLIJobs(cfg) - if err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + jobs, err := buildCLIJobs(cfg) + if err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } - if selected := selectionFromContext(ctx); len(selected) > 0 { + if selected := selectionFromContext(ctx); len(selected) > 0 { jobs, err = filterJobsBySelection(jobs, selected) if err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) return err } } - if len(jobs) == 0 { + if len(jobs) == 0 { return fmt.Errorf("hexai: no CLI providers configured") } // Prefer piped stdin when present; only open the editor when there are no args // and no stdin content available. - input, rerr := readInput(stdin, args) - if rerr != nil && len(args) == 0 { - if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" { + input, rerr := readInput(stdin, args) + if rerr != nil && len(args) == 0 { + if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" { args = []string{prompt} input, rerr = readInput(stdin, args) } } - if rerr != nil { - fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) + if rerr != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr } - msgs := buildMessagesFromConfig(cfg, input) + msgs := buildMessagesFromConfig(cfg, input) if err := runCLIJobs(ctx, jobs, msgs, input, stdout, stderr); err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err } - return nil + return nil } // RunWithClient executes the CLI flow using an already-constructed client. // Useful for testing and embedding. -func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error { +func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error { input, err := readInput(stdin, args) if err != nil { - fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) return err } - req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} + req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} printProviderInfo(stderr, client, req.model) msgs := buildMessages(input) - if err := runChat(ctx, client, req, msgs, input, stdout, stderr); err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + if err := runChat(ctx, client, req, msgs, input, stdout, stderr); err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) return err } return nil @@ -2924,33 +2982,33 @@ type cliJobResult struct { err error } -func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error { +func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input string, stdout, stderr io.Writer) error { results := make([]*cliJobResult, len(jobs)) var wg sync.WaitGroup var printer *columnPrinter - if len(jobs) > 0 { + if len(jobs) > 0 { printer = newColumnPrinter(stdout, jobs) printer.PrintHeader() } - for _, job := range jobs { + for _, job := range jobs { job := job wg.Add(1) printProviderInfo(stderr, job.client, job.req.model) - go func() { + go func() { defer wg.Done() var errBuf bytes.Buffer var outBuf bytes.Buffer jobMsgs := make([]llm.Message, len(msgs)) copy(jobMsgs, msgs) writer := io.Writer(&outBuf) - if printer != nil { + if printer != nil { writer = printer.Writer(job.index) } - err := runChat(ctx, job.client, job.req, jobMsgs, input, writer, &errBuf) - if printer != nil { + err := runChat(ctx, job.client, job.req, jobMsgs, input, writer, &errBuf) + if printer != nil { printer.Flush(job.index) } - results[job.index] = &cliJobResult{ + results[job.index] = &cliJobResult{ provider: job.client.Name(), model: job.req.model, output: outBuf.String(), @@ -2959,7 +3017,7 @@ func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input st } }() } - wg.Wait() + wg.Wait() var firstErr error if printer == nil { printed := false @@ -2989,48 +3047,48 @@ func runCLIJobs(ctx context.Context, jobs []cliJob, msgs []llm.Message, input st printed = true } } - for _, res := range results { + for _, res := range results { if res == nil { continue } - if res.summary != "" { + if res.summary != "" { summary := strings.TrimLeft(res.summary, "\n") - if summary != "" { + if summary != "" { if _, err := io.WriteString(stderr, summary); err != nil { return err } } } - if res.err != nil { + if res.err != nil { if _, err := fmt.Fprintf(stderr, logging.AnsiBase+"hexai: provider=%s model=%s error: %v"+logging.AnsiReset+"\n", res.provider, res.model, res.err); err != nil { return err } } - if firstErr == nil && res.err != nil { + if firstErr == nil && res.err != nil { firstErr = res.err } } - return firstErr + return firstErr } -func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter { +func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter { cols := len(jobs) width := detectTerminalWidth(stdout) - if width <= 0 { + if width <= 0 { width = 100 } - sepWidth := (cols - 1) * 3 + sepWidth := (cols - 1) * 3 colWidth := (width - sepWidth) / cols if colWidth < 20 { colWidth = 20 } - providers := make([]string, cols) + providers := make([]string, cols) models := make([]string, cols) - for _, job := range jobs { + for _, job := range jobs { providers[job.index] = job.client.Name() models[job.index] = job.req.model } - return &columnPrinter{ + return &columnPrinter{ stdout: stdout, columns: cols, colWidth: colWidth, @@ -3040,34 +3098,34 @@ func newColumnPrinter(stdout io.Writer, jobs []cliJob) *columnPrinter } -func detectTerminalWidth(w io.Writer) int { +func detectTerminalWidth(w io.Writer) int { type fder interface{ Fd() uintptr } if f, ok := w.(*os.File); ok { if width, _, err := term.GetSize(int(f.Fd())); err == nil { return width } } - if f, ok := w.(fder); ok { + if f, ok := w.(fder); ok { if width, _, err := term.GetSize(int(f.Fd())); err == nil { return width } } - return 0 + return 0 } -func (cp *columnPrinter) Writer(idx int) io.Writer { +func (cp *columnPrinter) Writer(idx int) io.Writer { return columnWriter{printer: cp, index: idx} } -func (cp *columnPrinter) PrintHeader() { +func (cp *columnPrinter) PrintHeader() { cp.mu.Lock() defer cp.mu.Unlock() combo := make([]string, cp.columns) - for i := 0; i < cp.columns; i++ { + for i := 0; i < cp.columns; i++ { provider := strings.TrimSpace(cp.providers[i]) model := strings.TrimSpace(cp.models[i]) switch { - case provider != "" && model != "": + case provider != "" && model != "": combo[i] = provider + ":" + model case provider != "": combo[i] = provider @@ -3077,62 +3135,62 @@ func (cp *columnPrinter) PrintHeader() { combo[i] = "" } } - cp.writeLine(combo) + cp.writeLine(combo) divider := make([]string, cp.columns) line := strings.Repeat("─", cp.colWidth) - for i := range divider { + for i := range divider { divider[i] = line } - cp.writeLine(divider) + cp.writeLine(divider) } -func (cp *columnPrinter) Flush(idx int) { +func (cp *columnPrinter) Flush(idx int) { cp.mu.Lock() defer cp.mu.Unlock() if idx < 0 || idx >= len(cp.partial) { return } - if cp.partial[idx] == "" { + if cp.partial[idx] == "" { return } - cp.emitJobLine(idx, cp.partial[idx]) + cp.emitJobLine(idx, cp.partial[idx]) cp.partial[idx] = "" } -func (w columnWriter) Write(p []byte) (int, error) { +func (w columnWriter) Write(p []byte) (int, error) { return w.printer.write(w.index, string(p)) } -func (cp *columnPrinter) write(idx int, data string) (int, error) { +func (cp *columnPrinter) write(idx int, data string) (int, error) { cp.mu.Lock() defer cp.mu.Unlock() if idx < 0 || idx >= len(cp.partial) { return len(data), nil } - data = strings.ReplaceAll(data, "\r", "") + data = strings.ReplaceAll(data, "\r", "") cp.partial[idx] += data for strings.Contains(cp.partial[idx], "\n") { line, rest, _ := strings.Cut(cp.partial[idx], "\n") cp.partial[idx] = rest cp.emitJobLine(idx, line) } - return len(data), nil + return len(data), nil } -func (cp *columnPrinter) emitJobLine(idx int, line string) { +func (cp *columnPrinter) emitJobLine(idx int, line string) { segments := cp.wrap(line) - for _, seg := range segments { + for _, seg := range segments { cells := make([]string, cp.columns) - if idx >= 0 && idx < len(cells) { + if idx >= 0 && idx < len(cells) { cells[idx] = seg } - cp.writeLine(cells) + cp.writeLine(cells) } } -func (cp *columnPrinter) wrap(text string) []string { +func (cp *columnPrinter) wrap(text string) []string { text = strings.ReplaceAll(text, "\t", " ") - if runewidth.StringWidth(text) <= cp.colWidth { + if runewidth.StringWidth(text) <= cp.colWidth { return []string{text} } var lines []string @@ -3157,28 +3215,28 @@ func (cp *columnPrinter) wrap(text string) []string return lines } -func (cp *columnPrinter) writeLine(cells []string) { +func (cp *columnPrinter) writeLine(cells []string) { if len(cells) < cp.columns { extra := make([]string, cp.columns-len(cells)) cells = append(cells, extra...) } - var builder strings.Builder - for i := 0; i < cp.columns; i++ { + var builder strings.Builder + for i := 0; i < cp.columns; i++ { cell := cells[i] width := runewidth.StringWidth(cell) if width > cp.colWidth { cell = runewidth.Truncate(cell, cp.colWidth, "…") width = runewidth.StringWidth(cell) } - builder.WriteString(cell) - if pad := cp.colWidth - width; pad > 0 { + builder.WriteString(cell) + if pad := cp.colWidth - width; pad > 0 { builder.WriteString(strings.Repeat(" ", pad)) } - if i != cp.columns-1 { + if i != cp.columns-1 { builder.WriteString(" │ ") } } - builder.WriteByte('\n') + builder.WriteByte('\n') _, _ = cp.stdout.Write([]byte(builder.String())) } @@ -3200,73 +3258,73 @@ func WithCLIConfigPath(ctx context.Context, path string) context.Context return context.WithValue(ctx, configPathContextKey{}, strings.TrimSpace(path)) } -func configPathFromContext(ctx context.Context) string { +func configPathFromContext(ctx context.Context) string { if ctx == nil { return "" } - if v, ok := ctx.Value(configPathContextKey{}).(string); ok { + if v, ok := ctx.Value(configPathContextKey{}).(string); ok { return strings.TrimSpace(v) } - return "" + return "" } -func selectionFromContext(ctx context.Context) []int { +func selectionFromContext(ctx context.Context) []int { if ctx == nil { return nil } - if v, ok := ctx.Value(selectionContextKey{}).([]int); ok { + if v, ok := ctx.Value(selectionContextKey{}).([]int); ok { cpy := make([]int, len(v)) copy(cpy, v) return cpy } - return nil + return nil } -func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) { +func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) { if len(indices) == 0 { return jobs, nil } - filtered := make([]cliJob, 0, len(indices)) + filtered := make([]cliJob, 0, len(indices)) seen := make(map[int]struct{}, len(indices)) - for _, idx := range indices { - if idx < 0 || idx >= len(jobs) { + for _, idx := range indices { + if idx < 0 || idx >= len(jobs) { return nil, fmt.Errorf("provider index %d out of range (0-%d)", idx, len(jobs)-1) } - if _, ok := seen[idx]; ok { + if _, ok := seen[idx]; ok { continue } - clone := jobs[idx] + clone := jobs[idx] filtered = append(filtered, clone) seen[idx] = struct{}{} } - for i := range filtered { + for i := range filtered { filtered[i].index = i } - if len(filtered) == 0 { + if len(filtered) == 0 { return nil, fmt.Errorf("no CLI providers matched selection") } - return filtered, nil + return filtered, nil } // readInput reads from stdin and args, then combines them per CLI rules. -func readInput(stdin io.Reader, args []string) (string, error) { +func readInput(stdin io.Reader, args []string) (string, error) { var stdinData string - if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { data, readErr := io.ReadAll(stdin) - if readErr != nil { + if readErr != nil { return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr) } - stdinData = strings.TrimSpace(string(data)) + stdinData = strings.TrimSpace(string(data)) } - argData := strings.TrimSpace(strings.Join(args, " ")) + argData := strings.TrimSpace(strings.Join(args, " ")) switch { - case stdinData != "" && argData != "": + case stdinData != "" && argData != "": return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil - case stdinData != "": + case stdinData != "": return stdinData, nil - case argData != "": + case argData != "": return argData, nil - default: + default: return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin") } } @@ -3275,99 +3333,112 @@ func readInput(stdin io.Reader, args []string) (string, error) { +func buildMessages(input string) []llm.Message { lower := strings.ToLower(input) system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation." - if strings.Contains(lower, "explain") { + if strings.Contains(lower, "explain") { system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context." } - return []llm.Message{ + return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, } } // buildMessagesFromConfig uses configured CLI system prompts. -func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { +func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { lower := strings.ToLower(input) system := cfg.PromptCLIDefaultSystem - if strings.Contains(lower, "explain") { - if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" { + if strings.Contains(lower, "explain") { + if strings.TrimSpace(cfg.PromptCLIExplainSystem) != "" { system = cfg.PromptCLIExplainSystem } } - return []llm.Message{ + return []llm.Message{ {Role: "system", Content: system}, {Role: "user", Content: input}, } } // runChat executes the chat request, handling streaming and summary output. -func runChat(ctx context.Context, client llm.Client, req requestArgs, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { +func runChat(ctx context.Context, client llm.Client, req requestArgs, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { start := time.Now() // Best-effort tmux status update (colored start heartbeat) model := strings.TrimSpace(req.model) if model == "" { model = client.DefaultModel() } - _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), model)) + _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), model)) var output string - if s, ok := client.(llm.Streamer); ok { + if s, ok := client.(llm.Streamer); ok { var b strings.Builder - if err := s.ChatStream(ctx, msgs, func(chunk string) { - b.WriteString(chunk) - fmt.Fprint(out, chunk) - }, req.options...); err != nil { + var streamErr error + if err := s.ChatStream(ctx, msgs, func(chunk string) { + if streamErr != nil { + return + } + b.WriteString(chunk) + if _, err := fmt.Fprint(out, chunk); err != nil { + streamErr = err + } + }, req.options...); err != nil { return err } - output = b.String() - } else { + if streamErr != nil { + return streamErr + } + output = b.String() + } else { txt, err := client.Chat(ctx, msgs, req.options...) - if err != nil { + if err != nil { + return err + } + output = txt + if _, err := fmt.Fprint(out, output); err != nil { return err } - output = txt - fmt.Fprint(out, output) } - dur := time.Since(start) + dur := time.Since(start) // Contribute to global stats and update tmux status sent := 0 - for _, m := range msgs { + for _, m := range msgs { sent += len(m.Content) } - recv := len(output) + recv := len(output) _ = stats.Update(ctx, client.Name(), model, sent, recv) snap, _ := stats.TakeSnapshot() minsWin := snap.Window.Minutes() if minsWin <= 0 { minsWin = 0.001 } - scopeReqs := int64(0) - if pe, ok := snap.Providers[client.Name()]; ok { - if mc, ok2 := pe.Models[model]; ok2 { + scopeReqs := int64(0) + if pe, ok := snap.Providers[client.Name()]; ok { + if mc, ok2 := pe.Models[model]; ok2 { scopeReqs = mc.Reqs } } - scopeRPM := float64(scopeReqs) / minsWin - fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", - client.Name(), model, dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM) - _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), model, scopeRPM, scopeReqs, snap.Window)) + scopeRPM := float64(scopeReqs) / minsWin + if _, err := fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d | global Σ reqs=%d rpm=%.2f"+logging.AnsiReset+"\n", + client.Name(), model, dur.Round(time.Millisecond), sent, recv, snap.Global.Reqs, snap.RPM); err != nil { + return err + } + _ = tmux.SetStatus(tmux.FormatGlobalStatusColored(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, client.Name(), model, scopeRPM, scopeReqs, snap.Window)) return nil } // printProviderInfo writes the provider/model line to stderr. -func printProviderInfo(errw io.Writer, client llm.Client, model string) { +func printProviderInfo(errw io.Writer, client llm.Client, model string) { if strings.TrimSpace(model) == "" { model = client.DefaultModel() } - fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), model) + _, _ = fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), model) } // 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) { return newClientFromApp(cfg) } +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) { return newClientFromApp(cfg) } -