summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-16 03:51:43 +0200
committerPaul Buetow <paul@buetow.org>2026-03-16 03:51:43 +0200
commitde3e878ad12bbd3e609bd5b7d741fc792c72f255 (patch)
tree06d92b93ea0ad532c5d3a761033baac05abe2a5e
parent2e9cabb1c8bf1f0246e513fe1f86a552e07eee94 (diff)
Decompose App God struct into embedded section structs
Replace 60+ flat fields in App with 4 embedded section structs: CoreConfig, ProviderConfig, PromptConfig, FeatureConfig. Go field promotion preserves all existing field access patterns. Updated flattenAppConfig to recurse into embedded structs for runtimeconfig. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--cmd/hexai/main_test.go8
-rw-r--r--internal/appconfig/app_sections.go334
-rw-r--r--internal/appconfig/config_features_test.go10
-rw-r--r--internal/appconfig/config_load.go38
-rw-r--r--internal/appconfig/config_types.go184
-rw-r--r--internal/hexaiaction/parse_test.go18
-rw-r--r--internal/hexaiaction/prompts_more_test.go20
-rw-r--r--internal/hexaicli/run_more_test.go2
-rw-r--r--internal/hexaicli/run_output_test.go24
-rw-r--r--internal/hexaicli/run_test.go48
-rw-r--r--internal/hexailsp/run_test.go6
-rw-r--r--internal/hexaimcp/run_test.go26
-rw-r--r--internal/llmutils/client_test.go24
-rw-r--r--internal/lsp/codeaction_custom_test.go18
-rw-r--r--internal/lsp/document_test.go45
-rw-r--r--internal/lsp/ignore_test.go12
-rw-r--r--internal/lsp/server_test.go4
-rw-r--r--internal/lsp/triggers_config_test.go4
-rw-r--r--internal/runtimeconfig/store.go66
-rw-r--r--internal/runtimeconfig/store_test.go54
-rw-r--r--internal/slashcommands/syncer_test.go20
-rw-r--r--internal/tmuxedit/run_test.go6
22 files changed, 463 insertions, 508 deletions
diff --git a/cmd/hexai/main_test.go b/cmd/hexai/main_test.go
index 7c20cc4..810dcb2 100644
--- a/cmd/hexai/main_test.go
+++ b/cmd/hexai/main_test.go
@@ -109,9 +109,11 @@ func TestSplitConfigPath(t *testing.T) {
func TestPickDefaultModel(t *testing.T) {
cfg := appconfig.App{
- OllamaModel: "llama3",
- AnthropicModel: "claude-sonnet",
- OpenAIModel: "gpt-4o",
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaModel: "llama3",
+ AnthropicModel: "claude-sonnet",
+ OpenAIModel: "gpt-4o",
+ },
}
tests := []struct {
provider string
diff --git a/internal/appconfig/app_sections.go b/internal/appconfig/app_sections.go
index ae60d7a..7422152 100644
--- a/internal/appconfig/app_sections.go
+++ b/internal/appconfig/app_sections.go
@@ -3,85 +3,119 @@ package appconfig
import "slices"
// CoreConfig contains core runtime and interaction settings.
+// It is embedded in App; JSON tags ensure marshalling works correctly.
type CoreConfig struct {
- MaxTokens int
- ContextMode string
- ContextWindowLines int
- MaxContextTokens int
- LogPreviewLimit int
- RequestTimeout int
- CodingTemperature *float64
- ManualInvokeMinPrefix int
- CompletionDebounceMs int
- CompletionThrottleMs int
- CompletionWaitAll *bool
- TriggerCharacters []string
- Provider string
- InlineOpen string
- InlineClose string
- ChatSuffix string
- ChatPrefixes []string
+ MaxTokens int `json:"max_tokens"`
+ ContextMode string `json:"context_mode"`
+ ContextWindowLines int `json:"context_window_lines"`
+ MaxContextTokens int `json:"max_context_tokens"`
+ LogPreviewLimit int `json:"log_preview_limit"`
+ RequestTimeout int `json:"request_timeout"`
+ // Single knob for LSP requests; if set, overrides hardcoded temps in LSP.
+ CodingTemperature *float64 `json:"coding_temperature"`
+ // Minimum identifier characters required for manual (TriggerKind=1) invoke
+ // to proceed without structural triggers. 0 means always allow.
+ ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"`
+ // Completion debounce in milliseconds. When > 0, the server waits until
+ // there has been no text change for at least this duration before sending
+ // an LLM completion request.
+ CompletionDebounceMs int `json:"completion_debounce_ms"`
+ // 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"`
+ // 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"`
+ TriggerCharacters []string `json:"trigger_characters"`
+ Provider string `json:"provider"`
+ // Inline prompt trigger characters (default: >!text> and >>!text>)
+ InlineOpen string `json:"inline_open"`
+ InlineClose string `json:"inline_close"`
+ // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;])
+ ChatSuffix string `json:"chat_suffix"`
+ ChatPrefixes []string `json:"chat_prefixes"`
}
// ProviderConfig contains provider endpoints/models and per-surface model overrides.
+// It is embedded in App; JSON tags ensure marshalling works correctly.
type ProviderConfig struct {
- OpenAIBaseURL string
- OpenAIModel string
- OpenAITemperature *float64
- OpenRouterBaseURL string
- OpenRouterModel string
- OpenRouterTemperature *float64
- OllamaBaseURL string
- OllamaModel string
- OllamaTemperature *float64
- AnthropicBaseURL string
- AnthropicModel string
- AnthropicTemperature *float64
- CompletionConfigs []SurfaceConfig
- CodeActionConfigs []SurfaceConfig
- ChatConfigs []SurfaceConfig
- CLIConfigs []SurfaceConfig
+ // Provider-specific options
+ OpenAIBaseURL string `json:"openai_base_url"`
+ OpenAIModel string `json:"openai_model"`
+ // Default temperature for OpenAI requests (nil means use provider default)
+ OpenAITemperature *float64 `json:"openai_temperature"`
+ OpenRouterBaseURL string `json:"openrouter_base_url"`
+ OpenRouterModel string `json:"openrouter_model"`
+ // Default temperature for OpenRouter requests (nil means use provider default)
+ OpenRouterTemperature *float64 `json:"openrouter_temperature"`
+ OllamaBaseURL string `json:"ollama_base_url"`
+ OllamaModel string `json:"ollama_model"`
+ // Default temperature for Ollama requests (nil means use provider default)
+ OllamaTemperature *float64 `json:"ollama_temperature"`
+ AnthropicBaseURL string `json:"anthropic_base_url"`
+ AnthropicModel string `json:"anthropic_model"`
+ // Default temperature for Anthropic requests (nil means use provider default)
+ AnthropicTemperature *float64 `json:"anthropic_temperature"`
+ // Per-surface provider/model configurations (ordered; first entry is primary)
+ CompletionConfigs []SurfaceConfig `json:"-"`
+ CodeActionConfigs []SurfaceConfig `json:"-"`
+ ChatConfigs []SurfaceConfig `json:"-"`
+ CLIConfigs []SurfaceConfig `json:"-"`
}
// PromptConfig contains all prompt templates and custom action prompts.
+// It is embedded in App; fields use json:"-" since prompts are not exposed via JSON.
type PromptConfig struct {
- PromptCompletionSystemGeneral string
- PromptCompletionSystemParams string
- PromptCompletionSystemInline string
- PromptCompletionUserGeneral string
- PromptCompletionUserParams string
- PromptCompletionExtraHeader string
- PromptNativeCompletion string
- PromptChatSystem string
- PromptCodeActionRewriteSystem string
- PromptCodeActionDiagnosticsSystem string
- PromptCodeActionDocumentSystem string
- PromptCodeActionRewriteUser string
- PromptCodeActionDiagnosticsUser string
- PromptCodeActionDocumentUser string
- PromptCodeActionGoTestSystem string
- PromptCodeActionGoTestUser string
- PromptCodeActionSimplifySystem string
- PromptCodeActionSimplifyUser string
- PromptCLIDefaultSystem string
- PromptCLIExplainSystem string
- CustomActions []CustomAction
- TmuxCustomMenuHotkey string
+ // Prompt templates (configured only via file; no env overrides)
+ // Completion
+ PromptCompletionSystemGeneral string `json:"-"`
+ PromptCompletionSystemParams string `json:"-"`
+ PromptCompletionSystemInline string `json:"-"`
+ PromptCompletionUserGeneral string `json:"-"`
+ PromptCompletionUserParams string `json:"-"`
+ PromptCompletionExtraHeader string `json:"-"`
+ // Provider-native code-completer
+ PromptNativeCompletion string `json:"-"`
+ // In-editor chat
+ PromptChatSystem string `json:"-"`
+ // Code actions
+ PromptCodeActionRewriteSystem string `json:"-"`
+ PromptCodeActionDiagnosticsSystem string `json:"-"`
+ PromptCodeActionDocumentSystem string `json:"-"`
+ PromptCodeActionRewriteUser string `json:"-"`
+ PromptCodeActionDiagnosticsUser string `json:"-"`
+ PromptCodeActionDocumentUser string `json:"-"`
+ PromptCodeActionGoTestSystem string `json:"-"`
+ PromptCodeActionGoTestUser string `json:"-"`
+ PromptCodeActionSimplifySystem string `json:"-"`
+ PromptCodeActionSimplifyUser string `json:"-"`
+ // CLI
+ PromptCLIDefaultSystem string `json:"-"`
+ PromptCLIExplainSystem string `json:"-"`
+ // Custom code actions and tmux integration
+ CustomActions []CustomAction `json:"-"`
+ TmuxCustomMenuHotkey string `json:"-"`
}
// FeatureConfig contains non-LLM feature toggles/integration settings.
+// It is embedded in App; fields use json:"-" since features are not exposed via JSON.
type FeatureConfig struct {
- StatsWindowMinutes int
- IgnoreGitignore *bool
- IgnoreExtraPatterns []string
- IgnoreLSPNotify *bool
- TmuxEditPopupWidth string
- TmuxEditPopupHeight string
- TmuxEditDefaultAgent string
- TmuxEditAgents []TmuxEditAgentCfg
- MCPPromptsDir string
- MCPSlashCommandSync bool
- MCPSlashCommandDir string
+ // Stats
+ StatsWindowMinutes int `json:"-"`
+ // Ignore: gitignore-aware file filtering for LSP
+ IgnoreGitignore *bool `json:"-"`
+ IgnoreExtraPatterns []string `json:"-"`
+ IgnoreLSPNotify *bool `json:"-"`
+ // TmuxEdit: popup editor settings for hexai-tmux-edit
+ TmuxEditPopupWidth string `json:"-"`
+ TmuxEditPopupHeight string `json:"-"`
+ TmuxEditDefaultAgent string `json:"-"`
+ TmuxEditAgents []TmuxEditAgentCfg `json:"-"`
+ // MCP: Model Context Protocol server settings
+ MCPPromptsDir string `json:"-"` // Directory for prompt storage
+ MCPSlashCommandSync bool `json:"-"` // Enable slash command sync
+ MCPSlashCommandDir string `json:"-"` // Directory for slash command files
}
// AppSections is the focused split of App into subsystem-specific config groups.
@@ -110,174 +144,72 @@ func (a *App) ApplySections(sections AppSections) {
a.ApplyFeatureSection(sections.Features)
}
-// CoreSection returns the core runtime and interaction settings.
+// CoreSection returns a deep copy of the core runtime and interaction settings.
+// Slices are cloned to prevent callers from mutating the original.
func (a App) CoreSection() CoreConfig {
- return CoreConfig{
- MaxTokens: a.MaxTokens,
- ContextMode: a.ContextMode,
- ContextWindowLines: a.ContextWindowLines,
- MaxContextTokens: a.MaxContextTokens,
- LogPreviewLimit: a.LogPreviewLimit,
- RequestTimeout: a.RequestTimeout,
- CodingTemperature: a.CodingTemperature,
- ManualInvokeMinPrefix: a.ManualInvokeMinPrefix,
- CompletionDebounceMs: a.CompletionDebounceMs,
- CompletionThrottleMs: a.CompletionThrottleMs,
- CompletionWaitAll: a.CompletionWaitAll,
- TriggerCharacters: slices.Clone(a.TriggerCharacters),
- Provider: a.Provider,
- InlineOpen: a.InlineOpen,
- InlineClose: a.InlineClose,
- ChatSuffix: a.ChatSuffix,
- ChatPrefixes: slices.Clone(a.ChatPrefixes),
- }
+ c := a.CoreConfig
+ c.TriggerCharacters = slices.Clone(a.TriggerCharacters)
+ c.ChatPrefixes = slices.Clone(a.ChatPrefixes)
+ return c
}
// ApplyCoreSection applies core runtime and interaction settings.
+// Slices are cloned to prevent the caller's copy from being shared.
func (a *App) ApplyCoreSection(core CoreConfig) {
- a.MaxTokens = core.MaxTokens
- a.ContextMode = core.ContextMode
- a.ContextWindowLines = core.ContextWindowLines
- a.MaxContextTokens = core.MaxContextTokens
- a.LogPreviewLimit = core.LogPreviewLimit
- a.RequestTimeout = core.RequestTimeout
- a.CodingTemperature = core.CodingTemperature
- a.ManualInvokeMinPrefix = core.ManualInvokeMinPrefix
- a.CompletionDebounceMs = core.CompletionDebounceMs
- a.CompletionThrottleMs = core.CompletionThrottleMs
- a.CompletionWaitAll = core.CompletionWaitAll
+ a.CoreConfig = core
a.TriggerCharacters = slices.Clone(core.TriggerCharacters)
- a.Provider = core.Provider
- a.InlineOpen = core.InlineOpen
- a.InlineClose = core.InlineClose
- a.ChatSuffix = core.ChatSuffix
a.ChatPrefixes = slices.Clone(core.ChatPrefixes)
}
-// ProviderSection returns provider endpoint/model settings and surface overrides.
+// ProviderSection returns a deep copy of provider endpoint/model settings.
+// Surface config slices are cloned to prevent callers from mutating the original.
func (a App) ProviderSection() ProviderConfig {
- return ProviderConfig{
- OpenAIBaseURL: a.OpenAIBaseURL,
- OpenAIModel: a.OpenAIModel,
- OpenAITemperature: a.OpenAITemperature,
- OpenRouterBaseURL: a.OpenRouterBaseURL,
- OpenRouterModel: a.OpenRouterModel,
- OpenRouterTemperature: a.OpenRouterTemperature,
- OllamaBaseURL: a.OllamaBaseURL,
- OllamaModel: a.OllamaModel,
- OllamaTemperature: a.OllamaTemperature,
- AnthropicBaseURL: a.AnthropicBaseURL,
- AnthropicModel: a.AnthropicModel,
- AnthropicTemperature: a.AnthropicTemperature,
- CompletionConfigs: cloneSurfaceConfigs(a.CompletionConfigs),
- CodeActionConfigs: cloneSurfaceConfigs(a.CodeActionConfigs),
- ChatConfigs: cloneSurfaceConfigs(a.ChatConfigs),
- CLIConfigs: cloneSurfaceConfigs(a.CLIConfigs),
- }
+ p := a.ProviderConfig
+ p.CompletionConfigs = cloneSurfaceConfigs(a.CompletionConfigs)
+ p.CodeActionConfigs = cloneSurfaceConfigs(a.CodeActionConfigs)
+ p.ChatConfigs = cloneSurfaceConfigs(a.ChatConfigs)
+ p.CLIConfigs = cloneSurfaceConfigs(a.CLIConfigs)
+ return p
}
// ApplyProviderSection applies provider endpoint/model settings and surface overrides.
+// Surface config slices are cloned to prevent the caller's copy from being shared.
func (a *App) ApplyProviderSection(providers ProviderConfig) {
- a.OpenAIBaseURL = providers.OpenAIBaseURL
- a.OpenAIModel = providers.OpenAIModel
- a.OpenAITemperature = providers.OpenAITemperature
- a.OpenRouterBaseURL = providers.OpenRouterBaseURL
- a.OpenRouterModel = providers.OpenRouterModel
- a.OpenRouterTemperature = providers.OpenRouterTemperature
- a.OllamaBaseURL = providers.OllamaBaseURL
- a.OllamaModel = providers.OllamaModel
- a.OllamaTemperature = providers.OllamaTemperature
- a.AnthropicBaseURL = providers.AnthropicBaseURL
- a.AnthropicModel = providers.AnthropicModel
- a.AnthropicTemperature = providers.AnthropicTemperature
+ a.ProviderConfig = providers
a.CompletionConfigs = cloneSurfaceConfigs(providers.CompletionConfigs)
a.CodeActionConfigs = cloneSurfaceConfigs(providers.CodeActionConfigs)
a.ChatConfigs = cloneSurfaceConfigs(providers.ChatConfigs)
a.CLIConfigs = cloneSurfaceConfigs(providers.CLIConfigs)
}
-// PromptSection returns prompt templates and custom action prompt settings.
+// PromptSection returns a deep copy of prompt templates and custom action settings.
+// The CustomActions slice is cloned to prevent callers from mutating the original.
func (a App) PromptSection() PromptConfig {
- return PromptConfig{
- PromptCompletionSystemGeneral: a.PromptCompletionSystemGeneral,
- PromptCompletionSystemParams: a.PromptCompletionSystemParams,
- PromptCompletionSystemInline: a.PromptCompletionSystemInline,
- PromptCompletionUserGeneral: a.PromptCompletionUserGeneral,
- PromptCompletionUserParams: a.PromptCompletionUserParams,
- PromptCompletionExtraHeader: a.PromptCompletionExtraHeader,
- PromptNativeCompletion: a.PromptNativeCompletion,
- PromptChatSystem: a.PromptChatSystem,
- PromptCodeActionRewriteSystem: a.PromptCodeActionRewriteSystem,
- PromptCodeActionDiagnosticsSystem: a.PromptCodeActionDiagnosticsSystem,
- PromptCodeActionDocumentSystem: a.PromptCodeActionDocumentSystem,
- PromptCodeActionRewriteUser: a.PromptCodeActionRewriteUser,
- PromptCodeActionDiagnosticsUser: a.PromptCodeActionDiagnosticsUser,
- PromptCodeActionDocumentUser: a.PromptCodeActionDocumentUser,
- PromptCodeActionGoTestSystem: a.PromptCodeActionGoTestSystem,
- PromptCodeActionGoTestUser: a.PromptCodeActionGoTestUser,
- PromptCodeActionSimplifySystem: a.PromptCodeActionSimplifySystem,
- PromptCodeActionSimplifyUser: a.PromptCodeActionSimplifyUser,
- PromptCLIDefaultSystem: a.PromptCLIDefaultSystem,
- PromptCLIExplainSystem: a.PromptCLIExplainSystem,
- CustomActions: append([]CustomAction{}, a.CustomActions...),
- TmuxCustomMenuHotkey: a.TmuxCustomMenuHotkey,
- }
+ p := a.PromptConfig
+ p.CustomActions = append([]CustomAction{}, a.CustomActions...)
+ return p
}
// ApplyPromptSection applies prompt templates and custom action prompt settings.
+// The CustomActions slice is cloned to prevent the caller's copy from being shared.
func (a *App) ApplyPromptSection(prompts PromptConfig) {
- a.PromptCompletionSystemGeneral = prompts.PromptCompletionSystemGeneral
- a.PromptCompletionSystemParams = prompts.PromptCompletionSystemParams
- a.PromptCompletionSystemInline = prompts.PromptCompletionSystemInline
- a.PromptCompletionUserGeneral = prompts.PromptCompletionUserGeneral
- a.PromptCompletionUserParams = prompts.PromptCompletionUserParams
- a.PromptCompletionExtraHeader = prompts.PromptCompletionExtraHeader
- a.PromptNativeCompletion = prompts.PromptNativeCompletion
- a.PromptChatSystem = prompts.PromptChatSystem
- a.PromptCodeActionRewriteSystem = prompts.PromptCodeActionRewriteSystem
- a.PromptCodeActionDiagnosticsSystem = prompts.PromptCodeActionDiagnosticsSystem
- a.PromptCodeActionDocumentSystem = prompts.PromptCodeActionDocumentSystem
- a.PromptCodeActionRewriteUser = prompts.PromptCodeActionRewriteUser
- a.PromptCodeActionDiagnosticsUser = prompts.PromptCodeActionDiagnosticsUser
- a.PromptCodeActionDocumentUser = prompts.PromptCodeActionDocumentUser
- a.PromptCodeActionGoTestSystem = prompts.PromptCodeActionGoTestSystem
- a.PromptCodeActionGoTestUser = prompts.PromptCodeActionGoTestUser
- a.PromptCodeActionSimplifySystem = prompts.PromptCodeActionSimplifySystem
- a.PromptCodeActionSimplifyUser = prompts.PromptCodeActionSimplifyUser
- a.PromptCLIDefaultSystem = prompts.PromptCLIDefaultSystem
- a.PromptCLIExplainSystem = prompts.PromptCLIExplainSystem
+ a.PromptConfig = prompts
a.CustomActions = append([]CustomAction{}, prompts.CustomActions...)
- a.TmuxCustomMenuHotkey = prompts.TmuxCustomMenuHotkey
}
-// FeatureSection returns non-LLM feature toggles and integrations.
+// FeatureSection returns a deep copy of non-LLM feature toggles and integrations.
+// Slices are cloned to prevent callers from mutating the original.
func (a App) FeatureSection() FeatureConfig {
- return FeatureConfig{
- StatsWindowMinutes: a.StatsWindowMinutes,
- IgnoreGitignore: a.IgnoreGitignore,
- IgnoreExtraPatterns: slices.Clone(a.IgnoreExtraPatterns),
- IgnoreLSPNotify: a.IgnoreLSPNotify,
- TmuxEditPopupWidth: a.TmuxEditPopupWidth,
- TmuxEditPopupHeight: a.TmuxEditPopupHeight,
- TmuxEditDefaultAgent: a.TmuxEditDefaultAgent,
- TmuxEditAgents: append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...),
- MCPPromptsDir: a.MCPPromptsDir,
- MCPSlashCommandSync: a.MCPSlashCommandSync,
- MCPSlashCommandDir: a.MCPSlashCommandDir,
- }
+ f := a.FeatureConfig
+ f.IgnoreExtraPatterns = slices.Clone(a.IgnoreExtraPatterns)
+ f.TmuxEditAgents = append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...)
+ return f
}
// ApplyFeatureSection applies non-LLM feature toggles and integrations.
+// Slices are cloned to prevent the caller's copy from being shared.
func (a *App) ApplyFeatureSection(features FeatureConfig) {
- a.StatsWindowMinutes = features.StatsWindowMinutes
- a.IgnoreGitignore = features.IgnoreGitignore
+ a.FeatureConfig = features
a.IgnoreExtraPatterns = slices.Clone(features.IgnoreExtraPatterns)
- a.IgnoreLSPNotify = features.IgnoreLSPNotify
- a.TmuxEditPopupWidth = features.TmuxEditPopupWidth
- a.TmuxEditPopupHeight = features.TmuxEditPopupHeight
- a.TmuxEditDefaultAgent = features.TmuxEditDefaultAgent
a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, features.TmuxEditAgents...)
- a.MCPPromptsDir = features.MCPPromptsDir
- a.MCPSlashCommandSync = features.MCPSlashCommandSync
- a.MCPSlashCommandDir = features.MCPSlashCommandDir
}
diff --git a/internal/appconfig/config_features_test.go b/internal/appconfig/config_features_test.go
index b3c12e9..9e3528a 100644
--- a/internal/appconfig/config_features_test.go
+++ b/internal/appconfig/config_features_test.go
@@ -182,10 +182,12 @@ func TestTmuxEditConfig_Merge(t *testing.T) {
clearHexaiEnv(t)
a := newDefaultConfig()
b := App{
- TmuxEditPopupWidth: "70%",
- TmuxEditDefaultAgent: "amp",
- TmuxEditAgents: []TmuxEditAgentCfg{
- {Name: "amp", DisplayName: "Amp"},
+ FeatureConfig: FeatureConfig{
+ TmuxEditPopupWidth: "70%",
+ TmuxEditDefaultAgent: "amp",
+ TmuxEditAgents: []TmuxEditAgentCfg{
+ {Name: "amp", DisplayName: "Amp"},
+ },
},
}
a.mergeWith(&b)
diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go
index dc917ff..37eaca3 100644
--- a/internal/appconfig/config_load.go
+++ b/internal/appconfig/config_load.go
@@ -290,14 +290,14 @@ func applyGeneralSection(fc *fileConfig, out *App) {
if (fc.General == sectionGeneral{}) && fc.General.CodingTemperature == nil {
return
}
- tmp := App{
+ tmp := App{CoreConfig: CoreConfig{
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)
}
@@ -305,7 +305,7 @@ func applyLoggingSection(fc *fileConfig, out *App) {
if fc.Logging == (sectionLogging{}) {
return
}
- out.mergeBasics(&App{LogPreviewLimit: fc.Logging.LogPreviewLimit})
+ out.mergeBasics(&App{CoreConfig: CoreConfig{LogPreviewLimit: fc.Logging.LogPreviewLimit}})
}
func applyCompletionSection(fc *fileConfig, out *App) {
@@ -315,12 +315,12 @@ func applyCompletionSection(fc *fileConfig, out *App) {
fc.Completion.CompletionWaitAll == nil {
return
}
- tmp := App{
+ tmp := App{CoreConfig: CoreConfig{
CompletionDebounceMs: fc.Completion.CompletionDebounceMs,
CompletionThrottleMs: fc.Completion.CompletionThrottleMs,
ManualInvokeMinPrefix: fc.Completion.ManualInvokeMinPrefix,
CompletionWaitAll: fc.Completion.CompletionWaitAll,
- }
+ }}
out.mergeBasics(&tmp)
}
@@ -328,39 +328,39 @@ func applyTriggerSection(fc *fileConfig, out *App) {
if len(fc.Triggers.TriggerCharacters) == 0 {
return
}
- out.mergeBasics(&App{TriggerCharacters: fc.Triggers.TriggerCharacters})
+ out.mergeBasics(&App{CoreConfig: CoreConfig{TriggerCharacters: fc.Triggers.TriggerCharacters}})
}
func applyInlineSection(fc *fileConfig, out *App) {
if fc.Inline == (sectionInline{}) {
return
}
- out.mergeBasics(&App{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose})
+ out.mergeBasics(&App{CoreConfig: CoreConfig{InlineOpen: fc.Inline.InlineOpen, InlineClose: fc.Inline.InlineClose}})
}
func applyChatSection(fc *fileConfig, out *App) {
if strings.TrimSpace(fc.Chat.ChatSuffix) == "" && len(fc.Chat.ChatPrefixes) == 0 {
return
}
- out.mergeBasics(&App{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes})
+ out.mergeBasics(&App{CoreConfig: CoreConfig{ChatSuffix: fc.Chat.ChatSuffix, ChatPrefixes: fc.Chat.ChatPrefixes}})
}
func applyProviderNameSection(fc *fileConfig, out *App) {
if strings.TrimSpace(fc.Provider.Name) == "" {
return
}
- out.mergeBasics(&App{Provider: fc.Provider.Name})
+ out.mergeBasics(&App{CoreConfig: CoreConfig{Provider: fc.Provider.Name}})
}
func applyIgnoreSection(fc *fileConfig, out *App) {
if fc.Ignore.Gitignore == nil && len(fc.Ignore.ExtraPatterns) == 0 && fc.Ignore.LSPNotifyIgnored == nil {
return
}
- tmp := App{
+ tmp := App{FeatureConfig: FeatureConfig{
IgnoreGitignore: fc.Ignore.Gitignore,
IgnoreExtraPatterns: fc.Ignore.ExtraPatterns,
IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored,
- }
+ }}
out.mergeBasics(&tmp)
}
@@ -368,11 +368,11 @@ func applyOpenAISection(fc *fileConfig, out *App) {
if fc.OpenAI.isZero() && fc.OpenAI.Temperature == nil {
return
}
- tmp := App{
+ tmp := App{ProviderConfig: ProviderConfig{
OpenAIBaseURL: fc.OpenAI.BaseURL,
OpenAIModel: fc.OpenAI.resolvedModel(),
OpenAITemperature: fc.OpenAI.Temperature,
- }
+ }}
out.mergeProviderFields(&tmp)
}
@@ -380,11 +380,11 @@ func applyOpenRouterSection(fc *fileConfig, out *App) {
if fc.OpenRouter == (sectionOpenRouter{}) && fc.OpenRouter.Temperature == nil {
return
}
- tmp := App{
+ tmp := App{ProviderConfig: ProviderConfig{
OpenRouterBaseURL: fc.OpenRouter.BaseURL,
OpenRouterModel: fc.OpenRouter.Model,
OpenRouterTemperature: fc.OpenRouter.Temperature,
- }
+ }}
out.mergeProviderFields(&tmp)
}
@@ -392,11 +392,11 @@ func applyOllamaSection(fc *fileConfig, out *App) {
if fc.Ollama == (sectionOllama{}) && fc.Ollama.Temperature == nil {
return
}
- tmp := App{
+ tmp := App{ProviderConfig: ProviderConfig{
OllamaBaseURL: fc.Ollama.BaseURL,
OllamaModel: fc.Ollama.Model,
OllamaTemperature: fc.Ollama.Temperature,
- }
+ }}
out.mergeProviderFields(&tmp)
}
@@ -404,11 +404,11 @@ func applyAnthropicSection(fc *fileConfig, out *App) {
if fc.Anthropic == (sectionAnthropic{}) && fc.Anthropic.Temperature == nil {
return
}
- tmp := App{
+ tmp := App{ProviderConfig: ProviderConfig{
AnthropicBaseURL: fc.Anthropic.BaseURL,
AnthropicModel: fc.Anthropic.Model,
AnthropicTemperature: fc.Anthropic.Temperature,
- }
+ }}
out.mergeProviderFields(&tmp)
}
diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go
index 59b02e3..d7fcc5d 100644
--- a/internal/appconfig/config_types.go
+++ b/internal/appconfig/config_types.go
@@ -11,114 +11,16 @@ type SurfaceConfig struct {
}
// App holds user-configurable settings read from ~/.config/hexai/config.toml.
+// Fields are organized into embedded section structs. Go promotes all fields,
+// so existing code like cfg.MaxTokens continues to work. App is never directly
+// TOML-decoded; the fileConfig struct handles TOML parsing and fields are copied
+// to App in loadFromFile. JSON marshalling works because section structs carry
+// the appropriate json tags.
type App struct {
- MaxTokens int `json:"max_tokens" toml:"max_tokens"`
- ContextMode string `json:"context_mode" toml:"context_mode"`
- ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"`
- MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"`
- LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"`
- RequestTimeout int `json:"request_timeout" toml:"request_timeout"`
- // Single knob for LSP requests; if set, overrides hardcoded temps in LSP.
- CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"`
- // Minimum identifier characters required for manual (TriggerKind=1) invoke
- // to proceed without structural triggers. 0 means always allow.
- ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix" toml:"manual_invoke_min_prefix"`
-
- // Completion debounce in milliseconds. When > 0, the server waits until
- // there has been no text change for at least this duration before sending
- // an LLM completion request.
- CompletionDebounceMs int `json:"completion_debounce_ms" toml:"completion_debounce_ms"`
- // Completion throttle in milliseconds. When > 0, caps the minimum spacing
- // between LLM requests (both chat and code-completer paths).
- CompletionThrottleMs int `json:"completion_throttle_ms" toml:"completion_throttle_ms"`
- // CompletionWaitAll controls whether to wait for all configured completion
- // backends before returning results. When true (default), waits for all
- // backends. When false, returns the first result immediately.
- CompletionWaitAll *bool `json:"completion_wait_all" toml:"completion_wait_all"`
-
- TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"`
- Provider string `json:"provider" toml:"provider"`
-
- // Inline prompt trigger characters (default: >!text> and >>!text>)
- InlineOpen string `json:"inline_open" toml:"inline_open"`
- InlineClose string `json:"inline_close" toml:"inline_close"`
- // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;])
- ChatSuffix string `json:"chat_suffix" toml:"chat_suffix"`
- ChatPrefixes []string `json:"chat_prefixes" toml:"chat_prefixes"`
-
- // Provider-specific options
- OpenAIBaseURL string `json:"openai_base_url" toml:"openai_base_url"`
- OpenAIModel string `json:"openai_model" toml:"openai_model"`
- // Default temperature for OpenAI requests (nil means use provider default)
- OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"`
- OpenRouterBaseURL string `json:"openrouter_base_url" toml:"openrouter_base_url"`
- OpenRouterModel string `json:"openrouter_model" toml:"openrouter_model"`
- // Default temperature for OpenRouter requests (nil means use provider default)
- OpenRouterTemperature *float64 `json:"openrouter_temperature" toml:"openrouter_temperature"`
- OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"`
- OllamaModel string `json:"ollama_model" toml:"ollama_model"`
- // Default temperature for Ollama requests (nil means use provider default)
- OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"`
- AnthropicBaseURL string `json:"anthropic_base_url" toml:"anthropic_base_url"`
- AnthropicModel string `json:"anthropic_model" toml:"anthropic_model"`
- // Default temperature for Anthropic requests (nil means use provider default)
- AnthropicTemperature *float64 `json:"anthropic_temperature" toml:"anthropic_temperature"`
-
- // Per-surface provider/model configurations (ordered; first entry is primary)
- CompletionConfigs []SurfaceConfig `json:"-" toml:"-"`
- CodeActionConfigs []SurfaceConfig `json:"-" toml:"-"`
- ChatConfigs []SurfaceConfig `json:"-" toml:"-"`
- CLIConfigs []SurfaceConfig `json:"-" toml:"-"`
-
- // Prompt templates (configured only via file; no env overrides)
- // Completion/chat/code action/CLI prompt strings. See config.toml.example for placeholders.
- // Completion
- PromptCompletionSystemGeneral string `json:"-" toml:"-"`
- PromptCompletionSystemParams string `json:"-" toml:"-"`
- PromptCompletionSystemInline string `json:"-" toml:"-"`
- PromptCompletionUserGeneral string `json:"-" toml:"-"`
- PromptCompletionUserParams string `json:"-" toml:"-"`
- PromptCompletionExtraHeader string `json:"-" toml:"-"`
- // Provider-native code-completer
- PromptNativeCompletion string `json:"-" toml:"-"`
- // In-editor chat
- PromptChatSystem string `json:"-" toml:"-"`
- // Code actions
- PromptCodeActionRewriteSystem string `json:"-" toml:"-"`
- PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"`
- PromptCodeActionDocumentSystem string `json:"-" toml:"-"`
- PromptCodeActionRewriteUser string `json:"-" toml:"-"`
- PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"`
- PromptCodeActionDocumentUser string `json:"-" toml:"-"`
- PromptCodeActionGoTestSystem string `json:"-" toml:"-"`
- PromptCodeActionGoTestUser string `json:"-" toml:"-"`
- PromptCodeActionSimplifySystem string `json:"-" toml:"-"`
- PromptCodeActionSimplifyUser string `json:"-" toml:"-"`
- // CLI
- PromptCLIDefaultSystem string `json:"-" toml:"-"`
- PromptCLIExplainSystem string `json:"-" toml:"-"`
-
- // Custom code actions and tmux integration
- CustomActions []CustomAction `json:"-" toml:"-"`
- TmuxCustomMenuHotkey string `json:"-" toml:"-"`
- // Stats
- StatsWindowMinutes int `json:"-" toml:"-"`
-
- // Ignore: gitignore-aware file filtering for LSP
- IgnoreGitignore *bool `json:"-" toml:"-"`
- IgnoreExtraPatterns []string `json:"-" toml:"-"`
- IgnoreLSPNotify *bool `json:"-" toml:"-"`
-
- // TmuxEdit: popup editor settings for hexai-tmux-edit
- TmuxEditPopupWidth string `json:"-" toml:"-"`
- TmuxEditPopupHeight string `json:"-" toml:"-"`
- TmuxEditDefaultAgent string `json:"-" toml:"-"`
- TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"`
-
- // MCP: Model Context Protocol server settings
- MCPPromptsDir string `json:"-" toml:"-"` // Directory for prompt storage
- MCPSlashCommandSync bool `json:"-" toml:"-"` // Enable slash command sync
- MCPSlashCommandDir string `json:"-" toml:"-"` // Directory for slash command files
+ CoreConfig
+ ProviderConfig
+ PromptConfig
+ FeatureConfig
}
// CustomAction describes a user-defined code action.
@@ -159,32 +61,49 @@ type LoadOptions struct {
ProjectRoot string
}
-// Constructor: defaults for App (kept first among functions)
+// Constructor: defaults for App (kept first among functions).
+// Initializes via embedded section structs; see CoreConfig, ProviderConfig,
+// PromptConfig, and FeatureConfig for field documentation.
func newDefaultConfig() App {
- // Coding-friendly default temperature across providers
+ // Coding-friendly default temperature across providers.
// Users can override per provider in config.toml (including 0.0).
t := 0.2
return App{
- MaxTokens: 4000,
- ContextMode: "always-full",
- ContextWindowLines: 120,
- MaxContextTokens: 4000,
- LogPreviewLimit: 100,
- RequestTimeout: 600,
- CodingTemperature: &t,
- OpenAITemperature: &t,
- OllamaTemperature: &t,
- AnthropicTemperature: &t,
- ManualInvokeMinPrefix: 0,
- CompletionDebounceMs: 800,
- CompletionThrottleMs: 0,
- // Inline/chat trigger defaults
- InlineOpen: ">!",
- InlineClose: ">",
- ChatSuffix: ">",
- ChatPrefixes: []string{"?", "!", ":", ";"},
-
- // Default prompt templates (match current hard-coded strings)
+ CoreConfig: CoreConfig{
+ MaxTokens: 4000,
+ ContextMode: "always-full",
+ ContextWindowLines: 120,
+ MaxContextTokens: 4000,
+ LogPreviewLimit: 100,
+ RequestTimeout: 600,
+ CodingTemperature: &t,
+ ManualInvokeMinPrefix: 0,
+ CompletionDebounceMs: 800,
+ CompletionThrottleMs: 0,
+ // Inline/chat trigger defaults
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ },
+ ProviderConfig: ProviderConfig{
+ OpenAITemperature: &t,
+ OllamaTemperature: &t,
+ AnthropicTemperature: &t,
+ },
+ PromptConfig: defaultPromptConfig(),
+ FeatureConfig: FeatureConfig{
+ StatsWindowMinutes: 60,
+ // Ignore: respect .gitignore by default, notify in LSP by default
+ IgnoreGitignore: boolPtr(true),
+ IgnoreLSPNotify: boolPtr(true),
+ },
+ }
+}
+
+// defaultPromptConfig returns the default prompt template values.
+func defaultPromptConfig() PromptConfig {
+ return PromptConfig{
PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.",
PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}",
PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).",
@@ -209,13 +128,6 @@ func newDefaultConfig() App {
PromptCLIDefaultSystem: "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation.",
PromptCLIExplainSystem: "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context.",
-
- // Stats
- StatsWindowMinutes: 60,
-
- // Ignore: respect .gitignore by default, notify in LSP by default
- IgnoreGitignore: boolPtr(true),
- IgnoreLSPNotify: boolPtr(true),
}
}
diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go
index ba5cd96..40ddd9a 100644
--- a/internal/hexaiaction/parse_test.go
+++ b/internal/hexaiaction/parse_test.go
@@ -81,14 +81,16 @@ func (f *fakeClient) DefaultModel() string { return "m" }
func TestRuners_Prompts(t *testing.T) {
cfg := appconfig.App{
- PromptCodeActionRewriteSystem: "SYS-R",
- PromptCodeActionRewriteUser: "R {{instruction}} :: {{selection}}",
- PromptCodeActionDiagnosticsSystem: "SYS-D",
- PromptCodeActionDiagnosticsUser: "D {{diagnostics}} :: {{selection}}",
- PromptCodeActionDocumentSystem: "SYS-C",
- PromptCodeActionDocumentUser: "C {{selection}}",
- PromptCodeActionGoTestSystem: "SYS-T",
- PromptCodeActionGoTestUser: "T {{function}}",
+ PromptConfig: appconfig.PromptConfig{
+ PromptCodeActionRewriteSystem: "SYS-R",
+ PromptCodeActionRewriteUser: "R {{instruction}} :: {{selection}}",
+ PromptCodeActionDiagnosticsSystem: "SYS-D",
+ PromptCodeActionDiagnosticsUser: "D {{diagnostics}} :: {{selection}}",
+ PromptCodeActionDocumentSystem: "SYS-C",
+ PromptCodeActionDocumentUser: "C {{selection}}",
+ PromptCodeActionGoTestSystem: "SYS-T",
+ PromptCodeActionGoTestUser: "T {{function}}",
+ },
}
f := &fakeClient{out: "```\nDONE\n```"}
ctx := context.Background()
diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go
index a4410e5..844bafe 100644
--- a/internal/hexaiaction/prompts_more_test.go
+++ b/internal/hexaiaction/prompts_more_test.go
@@ -33,10 +33,14 @@ func TestRunOnce_StripsFences(t *testing.T) {
func TestReqOptsFrom_Override(t *testing.T) {
cfg := appconfig.App{
- MaxTokens: 123,
- Provider: "openai",
- AnthropicModel: "claude-3-5-sonnet",
- CodeActionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "override", Temperature: ptrFloat(0.6)}},
+ CoreConfig: appconfig.CoreConfig{
+ MaxTokens: 123,
+ Provider: "openai",
+ },
+ ProviderConfig: appconfig.ProviderConfig{
+ AnthropicModel: "claude-3-5-sonnet",
+ CodeActionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "override", Temperature: ptrFloat(0.6)}},
+ },
}
req := reqOptsFrom(cfg)
if req.model != "override" {
@@ -52,7 +56,13 @@ func TestReqOptsFrom_Override(t *testing.T) {
}
func TestReqOptsFrom_Gpt5Temp(t *testing.T) {
- cfg := appconfig.App{Provider: "openai", CodingTemperature: ptrFloat(0.2), OpenAIModel: "gpt-5.0"}
+ cfg := appconfig.App{
+ CoreConfig: appconfig.CoreConfig{
+ Provider: "openai",
+ CodingTemperature: ptrFloat(0.2),
+ },
+ ProviderConfig: appconfig.ProviderConfig{OpenAIModel: "gpt-5.0"},
+ }
req := reqOptsFrom(cfg)
var opts llm.Options
for _, o := range req.options {
diff --git a/internal/hexaicli/run_more_test.go b/internal/hexaicli/run_more_test.go
index 469f0c0..125b0ff 100644
--- a/internal/hexaicli/run_more_test.go
+++ b/internal/hexaicli/run_more_test.go
@@ -36,7 +36,7 @@ func TestRunChat_Streaming(t *testing.T) {
}
func TestBuildMessagesFromConfig(t *testing.T) {
- cfg := appconfig.App{PromptCLIDefaultSystem: "DEF", PromptCLIExplainSystem: "EXP"}
+ cfg := appconfig.App{PromptConfig: appconfig.PromptConfig{PromptCLIDefaultSystem: "DEF", PromptCLIExplainSystem: "EXP"}}
msgs := buildMessagesFromConfig(cfg, "tell me")
if msgs[0].Content != "DEF" {
t.Fatalf("default system wrong: %q", msgs[0].Content)
diff --git a/internal/hexaicli/run_output_test.go b/internal/hexaicli/run_output_test.go
index f4e47fe..77a7c6a 100644
--- a/internal/hexaicli/run_output_test.go
+++ b/internal/hexaicli/run_output_test.go
@@ -350,8 +350,20 @@ func TestRunCLIJobs_MultiJob_WritesOutputs(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
jobs := []cliJob{
- {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
- {index: 1, provider: "b", cfg: appconfig.App{Provider: "b", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
+ {index: 0, provider: "a", cfg: appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "a"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaBaseURL: "http://x",
+ OllamaModel: "m",
+ },
+ }, req: requestArgs{model: "m"}},
+ {index: 1, provider: "b", cfg: appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "b"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaBaseURL: "http://x",
+ OllamaModel: "m",
+ },
+ }, req: requestArgs{model: "m"}},
}
msgs := buildMessages("hello")
var stdout, stderr bytes.Buffer
@@ -378,7 +390,13 @@ func TestRunCLIJobs_MultiJob_WritesOutputs(t *testing.T) {
// Also test the runCLIJobs single-job (streaming) path.
singleJobs := []cliJob{
- {index: 0, provider: "a", cfg: appconfig.App{Provider: "a", OllamaBaseURL: "http://x", OllamaModel: "m"}, req: requestArgs{model: "m"}},
+ {index: 0, provider: "a", cfg: appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "a"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaBaseURL: "http://x",
+ OllamaModel: "m",
+ },
+ }, req: requestArgs{model: "m"}},
}
stdout.Reset()
stderr.Reset()
diff --git a/internal/hexaicli/run_test.go b/internal/hexaicli/run_test.go
index be7bf6b..9711399 100644
--- a/internal/hexaicli/run_test.go
+++ b/internal/hexaicli/run_test.go
@@ -211,14 +211,20 @@ func TestExecuteCLIJobs_MultiProviderHeaderUsesStderr(t *testing.T) {
{
index: 0,
provider: "openai",
- cfg: appconfig.App{Provider: "openai", OpenAIModel: "gpt-4.1"},
- req: requestArgs{model: "gpt-4.1"},
+ cfg: appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "openai"},
+ ProviderConfig: appconfig.ProviderConfig{OpenAIModel: "gpt-4.1"},
+ },
+ req: requestArgs{model: "gpt-4.1"},
},
{
index: 1,
provider: "anthropic",
- cfg: appconfig.App{Provider: "anthropic", AnthropicModel: "claude"},
- req: requestArgs{model: "claude"},
+ cfg: appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "anthropic"},
+ ProviderConfig: appconfig.ProviderConfig{AnthropicModel: "claude"},
+ },
+ req: requestArgs{model: "claude"},
},
}
@@ -240,8 +246,8 @@ func TestExecuteCLIJobs_MultiProviderHeaderUsesStderr(t *testing.T) {
func TestBuildCLIRequest_Override(t *testing.T) {
cfg := appconfig.App{
- Provider: "openai",
- AnthropicModel: "claude-3-5-sonnet",
+ CoreConfig: appconfig.CoreConfig{Provider: "openai"},
+ ProviderConfig: appconfig.ProviderConfig{AnthropicModel: "claude-3-5-sonnet"},
}
entry := appconfig.SurfaceConfig{Provider: "anthropic", Model: "override", Temperature: floatPtr(0.7)}
req := buildCLIRequest(entry, "anthropic", cfg)
@@ -258,7 +264,7 @@ func TestBuildCLIRequest_Override(t *testing.T) {
}
func TestBuildCLIRequest_Gpt5Temp(t *testing.T) {
- cfg := appconfig.App{Provider: "openai", CodingTemperature: floatPtr(0.2)}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{Provider: "openai", CodingTemperature: floatPtr(0.2)}}
entry := appconfig.SurfaceConfig{}
cfg.OpenAIModel = "gpt-5.1"
req := buildCLIRequest(entry, "openai", cfg)
@@ -276,11 +282,13 @@ func TestBuildCLIRequest_Gpt5Temp(t *testing.T) {
func TestBuildCLIJobs_MultiEntries(t *testing.T) {
cfg := appconfig.App{
- Provider: "ollama",
- OllamaModel: "llama3",
- CLIConfigs: []appconfig.SurfaceConfig{
- {Provider: "openai", Model: "gpt-4o"},
- {Provider: "anthropic", Model: "claude"},
+ CoreConfig: appconfig.CoreConfig{Provider: "ollama"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaModel: "llama3",
+ CLIConfigs: []appconfig.SurfaceConfig{
+ {Provider: "openai", Model: "gpt-4o"},
+ {Provider: "anthropic", Model: "claude"},
+ },
},
}
jobs, err := buildCLIJobs(cfg)
@@ -319,7 +327,13 @@ func TestFilterJobsBySelection(t *testing.T) {
}
func TestNewClientFromConfig_Ollama(t *testing.T) {
- cfg := appconfig.App{Provider: "ollama", OllamaBaseURL: "http://x", OllamaModel: "m"}
+ cfg := appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "ollama"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OllamaBaseURL: "http://x",
+ OllamaModel: "m",
+ },
+ }
c, err := newClientFromConfig(cfg)
if err != nil || c == nil {
t.Fatalf("expected client: %v %v", c, err)
@@ -327,7 +341,13 @@ func TestNewClientFromConfig_Ollama(t *testing.T) {
}
func TestNewClientFromConfig_OpenAI_MissingKey(t *testing.T) {
- cfg := appconfig.App{Provider: "openai", OpenAIBaseURL: "https://api", OpenAIModel: "gpt"}
+ cfg := appconfig.App{
+ CoreConfig: appconfig.CoreConfig{Provider: "openai"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OpenAIBaseURL: "https://api",
+ OpenAIModel: "gpt",
+ },
+ }
t.Setenv("HEXAI_OPENAI_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
if _, err := newClientFromConfig(cfg); err == nil {
diff --git a/internal/hexailsp/run_test.go b/internal/hexailsp/run_test.go
index 743f064..2b0198a 100644
--- a/internal/hexailsp/run_test.go
+++ b/internal/hexailsp/run_test.go
@@ -99,8 +99,10 @@ func TestRunWithFactory_NormalizesContextMode_AndSetsPreviewLimit(t *testing.T)
var stderr bytes.Buffer
logger := log.New(&stderr, "hexai-lsp-server ", 0)
cfg := appconfig.App{
- ContextMode: " File-On-New-Func ",
- LogPreviewLimit: 3,
+ CoreConfig: appconfig.CoreConfig{
+ ContextMode: " File-On-New-Func ",
+ LogPreviewLimit: 3,
+ },
}
var gotOpts lsp.ServerOptions
factory := func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go
index dac6542..6bfe771 100644
--- a/internal/hexaimcp/run_test.go
+++ b/internal/hexaimcp/run_test.go
@@ -112,7 +112,7 @@ func TestGetPromptsDir(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := appconfig.App{
- MCPPromptsDir: tt.cfgValue,
+ FeatureConfig: appconfig.FeatureConfig{MCPPromptsDir: tt.cfgValue},
}
result, err := getPromptsDir(cfg)
@@ -424,7 +424,7 @@ func TestGetPromptsDir_XDGDataHome(t *testing.T) {
// TestGetPromptsDir_TildeInConfig verifies tilde expansion for config path.
func TestGetPromptsDir_TildeInConfig(t *testing.T) {
cfg := appconfig.App{
- MCPPromptsDir: "~/my-prompts",
+ FeatureConfig: appconfig.FeatureConfig{MCPPromptsDir: "~/my-prompts"},
}
result, err := getPromptsDir(cfg)
@@ -449,7 +449,7 @@ func TestGetPromptsDir_TildeInConfig(t *testing.T) {
func TestCreateSyncer_Disabled(t *testing.T) {
logger := log.New(io.Discard, "", 0)
cfg := appconfig.App{
- MCPSlashCommandSync: false,
+ FeatureConfig: appconfig.FeatureConfig{MCPSlashCommandSync: false},
}
syncer, err := createSyncer(cfg, logger)
@@ -467,8 +467,10 @@ func TestCreateSyncer_Enabled(t *testing.T) {
tmpDir := t.TempDir()
logger := log.New(io.Discard, "", 0)
cfg := appconfig.App{
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: tmpDir,
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: tmpDir,
+ },
}
syncer, err := createSyncer(cfg, logger)
@@ -485,8 +487,10 @@ func TestCreateSyncer_Enabled(t *testing.T) {
func TestCreateSyncer_Error(t *testing.T) {
logger := log.New(io.Discard, "", 0)
cfg := appconfig.App{
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: "", // empty directory triggers error
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: "",
+ },
}
_, err := createSyncer(cfg, logger)
@@ -646,9 +650,11 @@ func TestApplyOverrides(t *testing.T) {
t.Run("does not overwrite with zero values", func(t *testing.T) {
cfg := appconfig.App{
- MCPPromptsDir: "/existing/prompts",
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: "/existing/cmds",
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPPromptsDir: "/existing/prompts",
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: "/existing/cmds",
+ },
}
overrides := MCPOverrides{} // all zero values
applyOverrides(&cfg, overrides)
diff --git a/internal/llmutils/client_test.go b/internal/llmutils/client_test.go
index 0e38476..0cbd26d 100644
--- a/internal/llmutils/client_test.go
+++ b/internal/llmutils/client_test.go
@@ -8,7 +8,7 @@ import (
)
func TestNewClientFromApp_Ollama(t *testing.T) {
- cfg := appconfig.App{Provider: "ollama"}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{Provider: "ollama"}}
c, err := NewClientFromApp(cfg)
if err != nil || c == nil {
t.Fatalf("ollama client failed: %v %v", c, err)
@@ -17,7 +17,7 @@ func TestNewClientFromApp_Ollama(t *testing.T) {
func TestNewClientFromApp_OpenAI_WithKey(t *testing.T) {
t.Setenv("HEXAI_OPENAI_API_KEY", "test-key")
- cfg := appconfig.App{Provider: "openai"}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{Provider: "openai"}}
c, err := NewClientFromApp(cfg)
if err != nil || c == nil {
t.Fatalf("openai client failed: %v %v", c, err)
@@ -37,10 +37,12 @@ func TestCanonicalProvider(t *testing.T) {
func TestDefaultModelForProvider(t *testing.T) {
cfg := appconfig.App{
- OpenAIModel: "gpt-4.1",
- OpenRouterModel: "openrouter/auto",
- OllamaModel: "qwen3",
- AnthropicModel: "claude",
+ ProviderConfig: appconfig.ProviderConfig{
+ OpenAIModel: "gpt-4.1",
+ OpenRouterModel: "openrouter/auto",
+ OllamaModel: "qwen3",
+ AnthropicModel: "claude",
+ },
}
if got := DefaultModelForProvider(cfg, "openai"); got != "gpt-4.1" {
t.Fatalf("openai model = %q", got)
@@ -74,10 +76,12 @@ func TestDefaultModelForProvider_Fallbacks(t *testing.T) {
func TestConfigForProvider(t *testing.T) {
base := appconfig.App{
- Provider: "openai",
- OpenAIModel: "gpt-4.1",
- OllamaModel: "qwen3",
- AnthropicModel: "claude",
+ CoreConfig: appconfig.CoreConfig{Provider: "openai"},
+ ProviderConfig: appconfig.ProviderConfig{
+ OpenAIModel: "gpt-4.1",
+ OllamaModel: "qwen3",
+ AnthropicModel: "claude",
+ },
}
got := ConfigForProvider(base, "ollama", "qwen3-coder")
if got.Provider != "ollama" {
diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go
index 36f99d4..d7fe283 100644
--- a/internal/lsp/codeaction_custom_test.go
+++ b/internal/lsp/codeaction_custom_test.go
@@ -30,13 +30,17 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response {
func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
var out bytes.Buffer
cfg := appconfig.App{
- InlineOpen: ">!",
- InlineClose: ">",
- ChatSuffix: ">",
- ChatPrefixes: []string{"?", "!", ":", ";"},
- CustomActions: []appconfig.CustomAction{
- {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
- {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ CoreConfig: appconfig.CoreConfig{
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ },
+ PromptConfig: appconfig.PromptConfig{
+ CustomActions: []appconfig.CustomAction{
+ {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
+ {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ },
},
}
s := &Server{
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index 3dc970d..c805d22 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -12,27 +12,30 @@ import (
func newTestServer() *Server {
cfg := appconfig.App{
- InlineOpen: ">!",
- InlineClose: ">",
- ChatSuffix: ">",
- ChatPrefixes: []string{"?", "!", ":", ";"},
-
- PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.",
- PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}",
- PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).",
- PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.",
- PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.",
- PromptCompletionExtraHeader: "Additional context:\n{{context}}",
- PromptNativeCompletion: "// Path: {{path}}\n{{before}}",
- PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.",
- PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.",
- PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.",
- PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.",
- PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}",
- PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}",
- PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}",
- PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.",
- PromptCodeActionGoTestUser: "Function under test:\n{{function}}",
+ CoreConfig: appconfig.CoreConfig{
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ },
+ PromptConfig: appconfig.PromptConfig{
+ PromptCompletionSystemParams: "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types.",
+ PromptCompletionUserParams: "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}",
+ PromptCompletionSystemGeneral: "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression).",
+ PromptCompletionUserGeneral: "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet.",
+ PromptCompletionSystemInline: "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only.",
+ PromptCompletionExtraHeader: "Additional context:\n{{context}}",
+ PromptNativeCompletion: "// Path: {{path}}\n{{before}}",
+ PromptChatSystem: "You are a helpful coding assistant. Answer concisely and clearly.",
+ PromptCodeActionRewriteSystem: "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable.",
+ PromptCodeActionDiagnosticsSystem: "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes.",
+ PromptCodeActionDocumentSystem: "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks.",
+ PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}",
+ PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}",
+ PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}",
+ PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.",
+ PromptCodeActionGoTestUser: "Function under test:\n{{function}}",
+ },
}
return &Server{
logger: log.New(io.Discard, "", 0),
diff --git a/internal/lsp/ignore_test.go b/internal/lsp/ignore_test.go
index 7df7428..d7b6776 100644
--- a/internal/lsp/ignore_test.go
+++ b/internal/lsp/ignore_test.go
@@ -15,11 +15,13 @@ import (
// from the given gitRoot and extra patterns.
func newIgnoreTestServer(gitRoot string, useGI bool, extra []string, notifyIgnored *bool) *Server {
cfg := appconfig.App{
- IgnoreLSPNotify: notifyIgnored,
- InlineOpen: ">!",
- InlineClose: ">",
- ChatSuffix: ">",
- ChatPrefixes: []string{"?", "!", ":", ";"},
+ CoreConfig: appconfig.CoreConfig{
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ },
+ FeatureConfig: appconfig.FeatureConfig{IgnoreLSPNotify: notifyIgnored},
}
s := &Server{
logger: log.New(io.Discard, "", 0),
diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go
index 836e43f..dc20eed 100644
--- a/internal/lsp/server_test.go
+++ b/internal/lsp/server_test.go
@@ -11,7 +11,7 @@ import (
func TestPromptSetUsesConfigStoreSnapshot(t *testing.T) {
s := newTestServer()
- initial := appconfig.App{MaxTokens: 77}
+ initial := appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 77}}
store := runtimeconfig.New(initial)
s.configStore = store
@@ -75,7 +75,7 @@ func (stubLLMClient) DefaultModel() string { return "stub-model" }
func TestServerApplyOptions(t *testing.T) {
s := newTestServer()
client := stubLLMClient{}
- cfg := appconfig.App{MaxTokens: 88}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 88}}
opts := ServerOptions{Config: &cfg, Client: client}
s.ApplyOptions(opts)
if s.currentLLMClient() != client {
diff --git a/internal/lsp/triggers_config_test.go b/internal/lsp/triggers_config_test.go
index 193e117..dbcefd0 100644
--- a/internal/lsp/triggers_config_test.go
+++ b/internal/lsp/triggers_config_test.go
@@ -30,7 +30,7 @@ func TestShouldSuppressForChatTriggerEOL_CustomConfig(t *testing.T) {
func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) {
var out bytes.Buffer
- cfg := appconfig.App{InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"}}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"}}}
s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{Config: &cfg})
openStr, _, openChar, closeChar := s.inlineMarkers()
if openChar != '<' || closeChar != '>' {
@@ -68,7 +68,7 @@ func TestIsTriggerEvent_BareDoubleOpenBlocksEvenWithContextTriggerChar(t *testin
func TestDetectAndHandleChat_CustomConfig_InsertsReply(t *testing.T) {
var out bytes.Buffer
- cfg := appconfig.App{ChatSuffix: "#", ChatPrefixes: []string{")"}}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{ChatSuffix: "#", ChatPrefixes: []string{")"}}}
s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{Config: &cfg})
s.llmClient = fakeLLM{resp: "Hello\nmulti-line reply"}
uri := "file:///chat2.go"
diff --git a/internal/runtimeconfig/store.go b/internal/runtimeconfig/store.go
index 4ee7ada..b8d34b4 100644
--- a/internal/runtimeconfig/store.go
+++ b/internal/runtimeconfig/store.go
@@ -118,38 +118,60 @@ func Diff(oldCfg, newCfg appconfig.App) []Change {
return changes
}
+// flattenAppConfig converts an App config into a flat key/value map for diffing.
+// It recurses into embedded structs (CoreConfig, ProviderConfig, etc.) to reach
+// all leaf fields. Keys are derived from json tags, with fallbacks for fields
+// that use json:"-" (e.g. surface configs, stats).
func flattenAppConfig(cfg appconfig.App) map[string]string {
result := make(map[string]string)
- val := reflect.ValueOf(cfg)
+ flattenStructFields(reflect.ValueOf(cfg), result)
+ return result
+}
+
+// flattenStructFields iterates over struct fields, recursing into anonymous
+// (embedded) structs and extracting key/value pairs from leaf fields.
+func flattenStructFields(val reflect.Value, result map[string]string) {
typ := val.Type()
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
- key := strings.TrimSpace(field.Tag.Get("toml"))
- if key == "" || key == "-" {
- switch field.Name {
- case "StatsWindowMinutes":
- key = "stats_window_minutes"
- case "CompletionConfigs":
- key = "completion_configs"
- case "CodeActionConfigs":
- key = "code_action_configs"
- case "ChatConfigs":
- key = "chat_configs"
- case "CLIConfigs":
- key = "cli_configs"
- default:
- continue
- }
- }
- if idx := strings.Index(key, ","); idx >= 0 {
- key = key[:idx]
+ // Recurse into embedded (anonymous) structs to flatten their fields.
+ if field.Anonymous && field.Type.Kind() == reflect.Struct {
+ flattenStructFields(val.Field(i), result)
+ continue
}
- if key == "" || key == "-" {
+ key := fieldKey(field)
+ if key == "" {
continue
}
result[key] = stringifyValue(val.Field(i))
}
- return result
+}
+
+// fieldKey derives the flattened map key for a struct field from its json tag,
+// with manual fallbacks for fields tagged json:"-" that still need tracking.
+func fieldKey(field reflect.StructField) string {
+ key := strings.TrimSpace(field.Tag.Get("json"))
+ if key == "" || key == "-" {
+ // Manual fallbacks for fields hidden from JSON but needed in diffs.
+ switch field.Name {
+ case "StatsWindowMinutes":
+ return "stats_window_minutes"
+ case "CompletionConfigs":
+ return "completion_configs"
+ case "CodeActionConfigs":
+ return "code_action_configs"
+ case "ChatConfigs":
+ return "chat_configs"
+ case "CLIConfigs":
+ return "cli_configs"
+ default:
+ return ""
+ }
+ }
+ if idx := strings.Index(key, ","); idx >= 0 {
+ key = key[:idx]
+ }
+ return key
}
func stringifyValue(v reflect.Value) string {
diff --git a/internal/runtimeconfig/store_test.go b/internal/runtimeconfig/store_test.go
index 906d7f6..ca201a2 100644
--- a/internal/runtimeconfig/store_test.go
+++ b/internal/runtimeconfig/store_test.go
@@ -105,7 +105,7 @@ func TestSubscribe_NilListener(t *testing.T) {
}
func TestSubscribe_ReceivesUpdates(t *testing.T) {
- store := New(appconfig.App{MaxTokens: 100})
+ store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 100}})
var gotOld, gotNew appconfig.App
callCount := 0
@@ -115,7 +115,7 @@ func TestSubscribe_ReceivesUpdates(t *testing.T) {
callCount++
})
- store.Set(appconfig.App{MaxTokens: 200})
+ store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 200}})
if callCount != 1 {
t.Fatalf("expected listener called once, got %d", callCount)
}
@@ -125,7 +125,7 @@ func TestSubscribe_ReceivesUpdates(t *testing.T) {
// After unsubscribe, listener must not be called again.
unsub()
- store.Set(appconfig.App{MaxTokens: 300})
+ store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 300}})
if callCount != 1 {
t.Fatalf("expected listener not called after unsubscribe, got %d", callCount)
}
@@ -137,14 +137,14 @@ func TestSubscribe_MultipleListeners(t *testing.T) {
unsub0 := store.Subscribe(func(_, _ appconfig.App) { calls[0]++ })
unsub1 := store.Subscribe(func(_, _ appconfig.App) { calls[1]++ })
- store.Set(appconfig.App{MaxTokens: 1})
+ store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 1}})
if calls[0] != 1 || calls[1] != 1 {
t.Fatalf("expected both listeners called once: %v", calls)
}
// Unsubscribe first listener only.
unsub0()
- store.Set(appconfig.App{MaxTokens: 2})
+ store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 2}})
if calls[0] != 1 || calls[1] != 2 {
t.Fatalf("expected only second listener called: %v", calls)
}
@@ -152,8 +152,8 @@ func TestSubscribe_MultipleListeners(t *testing.T) {
}
func TestSet_ReturnsChanges(t *testing.T) {
- store := New(appconfig.App{MaxTokens: 10, Provider: "ollama"})
- changes := store.Set(appconfig.App{MaxTokens: 20, Provider: "ollama"})
+ store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 10, Provider: "ollama"}})
+ changes := store.Set(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 20, Provider: "ollama"}})
found := false
for _, ch := range changes {
if ch.Key == "max_tokens" {
@@ -169,7 +169,7 @@ func TestSet_ReturnsChanges(t *testing.T) {
}
func TestSet_NoChanges(t *testing.T) {
- cfg := appconfig.App{MaxTokens: 10}
+ cfg := appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 10}}
store := New(cfg)
changes := store.Set(cfg)
if len(changes) != 0 {
@@ -181,7 +181,7 @@ func TestReload_NilLogger(t *testing.T) {
// Reload with nil logger should not panic; it exercises the nil-logger guard
// in Reload (skipping logger.Print). LoadWithOptions returns defaults when
// logger is nil, so the store gets default config applied.
- store := New(appconfig.App{MaxTokens: 1})
+ store := New(appconfig.App{CoreConfig: appconfig.CoreConfig{MaxTokens: 1}})
changes, err := store.Reload(nil, appconfig.LoadOptions{IgnoreEnv: true})
if err != nil {
t.Fatalf("reload failed: %v", err)
@@ -227,8 +227,8 @@ func TestStringifyValue_BoolAndFloat(t *testing.T) {
// Exercise the bool and float branches via Diff on App fields.
temp1 := 0.5
temp2 := 0.9
- oldCfg := appconfig.App{CodingTemperature: &temp1}
- newCfg := appconfig.App{CodingTemperature: &temp2}
+ oldCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp1}}
+ newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp2}}
changes := Diff(oldCfg, newCfg)
found := false
for _, ch := range changes {
@@ -248,7 +248,7 @@ func TestStringifyValue_NilPointer(t *testing.T) {
// nil *float64 should produce "(unset)".
oldCfg := appconfig.App{}
temp := 0.3
- newCfg := appconfig.App{CodingTemperature: &temp}
+ newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CodingTemperature: &temp}}
changes := Diff(oldCfg, newCfg)
found := false
for _, ch := range changes {
@@ -268,7 +268,7 @@ func TestStringifyValue_NilBoolPointer(t *testing.T) {
// CompletionWaitAll is *bool; nil should produce "(unset)".
b := true
oldCfg := appconfig.App{}
- newCfg := appconfig.App{CompletionWaitAll: &b}
+ newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{CompletionWaitAll: &b}}
changes := Diff(oldCfg, newCfg)
found := false
for _, ch := range changes {
@@ -286,8 +286,8 @@ func TestStringifyValue_NilBoolPointer(t *testing.T) {
func TestStringifyValue_StringSlice(t *testing.T) {
// TriggerCharacters is []string; exercise the string-slice branch.
- oldCfg := appconfig.App{TriggerCharacters: []string{".", ":"}}
- newCfg := appconfig.App{TriggerCharacters: []string{".", ":", "("}}
+ oldCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{".", ":"}}}
+ newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{".", ":", "("}}}
changes := Diff(oldCfg, newCfg)
found := false
for _, ch := range changes {
@@ -309,7 +309,7 @@ func TestStringifyValue_StringSlice(t *testing.T) {
func TestStringifyValue_NilSlice(t *testing.T) {
// nil slice vs non-nil slice.
oldCfg := appconfig.App{}
- newCfg := appconfig.App{TriggerCharacters: []string{"x"}}
+ newCfg := appconfig.App{CoreConfig: appconfig.CoreConfig{TriggerCharacters: []string{"x"}}}
changes := Diff(oldCfg, newCfg)
found := false
for _, ch := range changes {
@@ -329,13 +329,17 @@ func TestStringifyValue_SurfaceConfigWithTemperature(t *testing.T) {
// Exercise the SurfaceConfig temperature branch.
temp := 0.750
oldCfg := appconfig.App{
- CompletionConfigs: []appconfig.SurfaceConfig{
- {Provider: "openai", Model: "gpt-4o", Temperature: &temp},
+ ProviderConfig: appconfig.ProviderConfig{
+ CompletionConfigs: []appconfig.SurfaceConfig{
+ {Provider: "openai", Model: "gpt-4o", Temperature: &temp},
+ },
},
}
newCfg := appconfig.App{
- CompletionConfigs: []appconfig.SurfaceConfig{
- {Provider: "openai", Model: "gpt-4o"},
+ ProviderConfig: appconfig.ProviderConfig{
+ CompletionConfigs: []appconfig.SurfaceConfig{
+ {Provider: "openai", Model: "gpt-4o"},
+ },
},
}
changes := Diff(oldCfg, newCfg)
@@ -356,8 +360,10 @@ func TestStringifyValue_SurfaceConfigWithTemperature(t *testing.T) {
func TestStringifyValue_SurfaceConfigEmptyProvider(t *testing.T) {
// Exercise the SurfaceConfig branch where provider is empty.
oldCfg := appconfig.App{
- ChatConfigs: []appconfig.SurfaceConfig{
- {Provider: "", Model: "some-model"},
+ ProviderConfig: appconfig.ProviderConfig{
+ ChatConfigs: []appconfig.SurfaceConfig{
+ {Provider: "", Model: "some-model"},
+ },
},
}
newCfg := appconfig.App{}
@@ -377,8 +383,8 @@ func TestStringifyValue_SurfaceConfigEmptyProvider(t *testing.T) {
}
func TestDiff_SurfaceModel(t *testing.T) {
- oldCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}}
- newCfg := appconfig.App{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}}
+ oldCfg := appconfig.App{ProviderConfig: appconfig.ProviderConfig{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "openai", Model: "gpt-4o"}}}}
+ newCfg := appconfig.App{ProviderConfig: appconfig.ProviderConfig{CompletionConfigs: []appconfig.SurfaceConfig{{Provider: "anthropic", Model: "claude-3-5-sonnet"}}}}
changes := Diff(oldCfg, newCfg)
if len(changes) == 0 {
t.Fatalf("expected diff entries, got none")
diff --git a/internal/slashcommands/syncer_test.go b/internal/slashcommands/syncer_test.go
index 7ae13dd..01c6d28 100644
--- a/internal/slashcommands/syncer_test.go
+++ b/internal/slashcommands/syncer_test.go
@@ -13,7 +13,7 @@ import (
func TestNewSyncer_Disabled(t *testing.T) {
cfg := appconfig.App{
- MCPSlashCommandSync: false,
+ FeatureConfig: appconfig.FeatureConfig{MCPSlashCommandSync: false},
}
syncer, err := NewSyncer(cfg)
@@ -28,8 +28,10 @@ func TestNewSyncer_Disabled(t *testing.T) {
func TestNewSyncer_NoDirectory(t *testing.T) {
cfg := appconfig.App{
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: "",
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: "",
+ },
}
_, err := NewSyncer(cfg)
@@ -43,8 +45,10 @@ func TestNewSyncer_CreatesDirectory(t *testing.T) {
testDir := filepath.Join(tmpDir, "test-commands")
cfg := appconfig.App{
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: testDir,
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: testDir,
+ },
}
syncer, err := NewSyncer(cfg)
@@ -71,8 +75,10 @@ func TestNewSyncer_ExpandsHomeDirectory(t *testing.T) {
defer os.Setenv("HOME", home)
cfg := appconfig.App{
- MCPSlashCommandSync: true,
- MCPSlashCommandDir: "~/test-commands",
+ FeatureConfig: appconfig.FeatureConfig{
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: "~/test-commands",
+ },
}
syncer, err := NewSyncer(cfg)
diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go
index c150cbd..ff36e4c 100644
--- a/internal/tmuxedit/run_test.go
+++ b/internal/tmuxedit/run_test.go
@@ -167,8 +167,10 @@ func TestRunWithConfig_CustomDimensions(t *testing.T) {
sendKeys = func(string, ...string) error { return nil }
cfg := appconfig.App{
- TmuxEditPopupWidth: "90%",
- TmuxEditPopupHeight: "85%",
+ FeatureConfig: appconfig.FeatureConfig{
+ TmuxEditPopupWidth: "90%",
+ TmuxEditPopupHeight: "85%",
+ },
}
err := runWithConfig(Options{}, cfg)
if err != nil {