Files
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

252 lines
5.3 KiB
Go

package session
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// LocalConfig holds all configuration for a Local session.
type LocalConfig struct {
Engine *engine.Engine
Provider string
Model string
SessionID string // identifies this session on disk
TurnCount int // seed from restored snapshot; 0 for new sessions
Store *SessionStore // nil = no persistence
Incognito *security.IncognitoMode // nil = always persist
Logger *slog.Logger // nil = slog.Default()
}
// Local implements Session using goroutines and channels within the same process.
type Local struct {
mu sync.Mutex
eng *engine.Engine
state SessionState
events chan stream.Event
// Current turn context
cancel context.CancelFunc
turn *engine.Turn
err error
// Stats
provider string
model string
title string
turnCount int
// Persistence
sessionID string
store *SessionStore
incognito *security.IncognitoMode
createdAt time.Time
logger *slog.Logger
}
// NewLocal creates a channel-based in-process session.
func NewLocal(cfg LocalConfig) *Local {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Local{
eng: cfg.Engine,
state: StateIdle,
provider: cfg.Provider,
model: cfg.Model,
turnCount: cfg.TurnCount,
sessionID: cfg.SessionID,
store: cfg.Store,
incognito: cfg.Incognito,
createdAt: time.Now(),
logger: logger,
}
}
// SessionID returns the persistent identifier for this session.
func (s *Local) SessionID() string {
return s.sessionID
}
func (s *Local) Send(input string) error {
return s.SendWithOptions(input, engine.TurnOptions{})
}
// SendWithOptions is like Send but applies per-turn engine options.
func (s *Local) SendWithOptions(input string, opts engine.TurnOptions) error {
s.mu.Lock()
if s.state != StateIdle {
s.mu.Unlock()
return fmt.Errorf("session not idle (state: %s)", s.state)
}
s.state = StateStreaming
s.events = make(chan stream.Event, 64)
s.turn = nil
s.err = nil
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.turnCount++
if s.title == "" {
s.title = sessionTitle(input)
}
s.mu.Unlock()
// Run engine in background goroutine
go func() {
cb := func(evt stream.Event) {
select {
case s.events <- evt:
case <-ctx.Done():
}
}
turn, err := s.eng.SubmitWithOptions(ctx, input, opts, cb)
s.mu.Lock()
s.turn = turn
s.err = err
var finalState SessionState
if err != nil && ctx.Err() != nil {
s.state = StateCancelled
finalState = StateCancelled
} else if err != nil {
s.state = StateError
finalState = StateError
} else {
s.state = StateIdle
finalState = StateIdle
}
s.mu.Unlock()
// Auto-save after successful turn (outside lock to avoid holding it during I/O)
if finalState == StateIdle && s.store != nil && (s.incognito == nil || s.incognito.ShouldPersist()) {
snap := Snapshot{
ID: s.sessionID,
Metadata: Metadata{
ID: s.sessionID,
Title: s.title,
Provider: s.provider,
Model: s.model,
TurnCount: s.turnCount,
Usage: s.eng.Usage(),
CreatedAt: s.createdAt,
UpdatedAt: time.Now(),
MessageCount: len(s.eng.History()),
},
Messages: s.eng.History(),
}
if saveErr := s.store.Save(snap); saveErr != nil {
s.logger.Warn("session auto-save failed", "error", saveErr)
}
}
close(s.events)
}()
return nil
}
func (s *Local) Events() <-chan stream.Event {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func (s *Local) TurnResult() (*engine.Turn, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.turn, s.err
}
func (s *Local) Cancel() {
s.mu.Lock()
defer s.mu.Unlock()
if s.cancel != nil {
s.cancel()
}
}
func (s *Local) ResetError() {
s.mu.Lock()
defer s.mu.Unlock()
if s.state == StateError {
s.state = StateIdle
s.err = nil
}
}
func (s *Local) Close() error {
s.Cancel()
s.mu.Lock()
defer s.mu.Unlock()
s.state = StateClosed
return nil
}
// SetModel updates the displayed model name.
func (s *Local) SetModel(model string) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
}
// SetProvider updates the displayed provider name.
func (s *Local) SetProvider(provider string) {
s.mu.Lock()
defer s.mu.Unlock()
s.provider = provider
}
func (s *Local) Status() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := Status{
State: s.state,
Provider: s.provider,
Model: s.model,
TokensUsed: s.eng.Usage().TotalTokens(),
TurnCount: s.turnCount,
TokenState: "ok",
ToolsAvailable: s.eng.ToolsAvailable(),
}
if w := s.eng.ContextWindow(); w != nil {
tr := w.Tracker()
st.TokensMax = tr.MaxTokens()
st.TokenPercent = tr.PercentUsed()
st.TokenState = tr.State().String()
}
return st
}
// sessionTitle derives a short title from the first user message.
func sessionTitle(input string) string {
// Take first line, trim whitespace
line := input
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
line = line[:idx]
}
line = strings.TrimSpace(line)
if line == "" {
return ""
}
const maxLen = 60
if len(line) > maxLen {
line = line[:maxLen] + "…"
}
return line
}