Files
gnoma/internal/config/write.go
T
vikingowl e38cce5f1f fix(tui): security hardening, race-safety, and event handling fixes
Bundles the pending TUI work into a coherent batch. Bug fixes from
external review:

* expandPlaceholders: single-pass alternation regex over the original
  input prevents `#p\d+` / `#img\d+` tokens inside pasted content from
  being re-expanded after the bracket form is inlined.
* /incognito: gate savePromptHistory and the Ctrl+V image-write branch
  on `!m.incognito` so the no-persistence contract holds.
* history.txt: write at mode 0600 (chmod existing 0644 files), create
  parent dir at 0700, truncate to 500 entries on every save, slog.Warn
  on errors instead of swallowing.
* triggerPickerAction: guard m.config.Engine before SetModel, matching
  the /model handler.
* Picker key handler: navigation/enter/q consume, escape/ctrl+c close
  the picker AND fall through to global handlers (so streaming cancel
  and double-tap quit work with an overlay open), default swallows
  stray input.
* Paste line count: report total non-empty lines instead of newline
  count, ignoring trailing newlines (no more "+0 lines" for "abc").
* Ctrl+O restored to expand-output; Ctrl+Y is the new copy-response
  bind. /keys help text updated; picker help entries reordered.
* Tighter perms on .gnoma/pasted_image_*.png (0600).

Race-safety refactor: ApplyTheme used to mutate ~25 package-level
lipgloss styles in place. Replaced with an immutable themeStyles
snapshot and atomic.Pointer[themeStyles] swap. Readers go through a
theme() helper (one atomic load) instead of touching package vars
directly. No locks, no nested-RLock risk if rendering ever moves
off-thread.

Includes pre-existing in-flight work: TUISection in config with
persistent theme/vim settings; /copy /theme /vim slash commands;
provider-name completion; session.SetProvider for the provider picker.

Tests: placeholder_test.go (6 regression + happy-path cases including
the pasted-content collision), history_test.go (5 cases covering perms
on new and existing files, on-disk truncation, blank-input, newline
flattening), provider_test.go (provider switching + picker transitions
+ SLM gating).
2026-05-22 11:50:12 +02:00

100 lines
2.4 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
)
// SetProjectConfig writes a single key=value to the project config file (.gnoma/config.toml).
// Only whitelisted keys are supported.
func SetProjectConfig(key, value string) error {
return setConfig(projectConfigPath(), key, value)
}
// SetGlobalConfig writes a single key=value to the global config file (~/.config/gnoma/config.toml).
// Only whitelisted keys are supported.
func SetGlobalConfig(key, value string) error {
return setConfig(globalConfigPath(), key, value)
}
func setConfig(path, key, value string) error {
allowed := map[string]bool{
"provider.default": true,
"provider.model": true,
"permission.mode": true,
"slm.model_url": true,
"slm.enabled": true,
"slm.data_dir": true,
"tui.theme": true,
"tui.vim": true,
}
if !allowed[key] {
return fmt.Errorf("unknown config key %q (supported: %s)", key, strings.Join(allowedKeys(), ", "))
}
// Load existing config or start fresh
var cfg Config
if data, err := os.ReadFile(path); err == nil {
toml.Decode(string(data), &cfg) //nolint:errcheck
}
if cfg.Provider.APIKeys == nil {
cfg.Provider.APIKeys = make(map[string]string)
}
if cfg.Provider.Endpoints == nil {
cfg.Provider.Endpoints = make(map[string]string)
}
// Apply the change
switch key {
case "provider.default":
cfg.Provider.Default = value
case "provider.model":
cfg.Provider.Model = value
case "permission.mode":
cfg.Permission.Mode = value
case "slm.model_url":
cfg.SLM.ModelURL = value
case "slm.enabled":
cfg.SLM.Enabled = value == "true"
case "slm.data_dir":
cfg.SLM.DataDir = value
case "tui.theme":
cfg.TUI.Theme = value
case "tui.vim":
cfg.TUI.Vim = value == "true"
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
// Write
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create config file: %w", err)
}
enc := toml.NewEncoder(f)
encErr := enc.Encode(cfg)
closeErr := f.Close()
if encErr != nil {
return encErr
}
if closeErr != nil {
return fmt.Errorf("close config file: %w", closeErr)
}
return nil
}
func allowedKeys() []string {
return []string{
"provider.default", "provider.model", "permission.mode",
"slm.model_url", "slm.enabled", "slm.data_dir",
"tui.theme", "tui.vim",
}
}