e38cce5f1f
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).
327 lines
8.4 KiB
Go
327 lines
8.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/engine"
|
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
|
"somegit.dev/Owlibou/gnoma/internal/security"
|
|
"somegit.dev/Owlibou/gnoma/internal/session"
|
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
|
"somegit.dev/Owlibou/gnoma/internal/tool"
|
|
)
|
|
|
|
type mockProvider struct {
|
|
name string
|
|
defaultModel string
|
|
}
|
|
|
|
func (m *mockProvider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockProvider) Name() string {
|
|
return m.name
|
|
}
|
|
|
|
func (m *mockProvider) Models(ctx context.Context) ([]provider.ModelInfo, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockProvider) DefaultModel() string {
|
|
return m.defaultModel
|
|
}
|
|
|
|
func newTestRouterAndEngine() (*router.Router, *engine.Engine, router.SecureProvider, router.SecureProvider) {
|
|
rtr := router.New(router.Config{})
|
|
p1 := security.WrapProvider(&mockProvider{name: "anthropic", defaultModel: "claude-3-5-sonnet"}, nil)
|
|
p2 := security.WrapProvider(&mockProvider{name: "openai", defaultModel: "gpt-4o"}, nil)
|
|
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("anthropic", "claude-3-5-sonnet"),
|
|
Provider: p1,
|
|
ModelName: "claude-3-5-sonnet",
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("openai", "gpt-4o"),
|
|
Provider: p2,
|
|
ModelName: "gpt-4o",
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("openai", "gpt-3.5-turbo"),
|
|
Provider: p2,
|
|
ModelName: "gpt-3.5-turbo",
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
|
|
eng, err := engine.New(engine.Config{
|
|
Provider: p1,
|
|
Model: "claude-3-5-sonnet",
|
|
Tools: tool.NewRegistry(),
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return rtr, eng, p1, p2
|
|
}
|
|
|
|
func TestGetAvailableProviders(t *testing.T) {
|
|
rtr, _, _, _ := newTestRouterAndEngine()
|
|
m := Model{
|
|
config: Config{
|
|
Router: rtr,
|
|
},
|
|
}
|
|
|
|
provs := m.getAvailableProviders()
|
|
if len(provs) != 2 {
|
|
t.Fatalf("expected 2 providers, got %d", len(provs))
|
|
}
|
|
if provs[0] != "anthropic" || provs[1] != "openai" {
|
|
t.Errorf("expected [anthropic, openai], got %v", provs)
|
|
}
|
|
}
|
|
|
|
func TestFindBestArmForProvider(t *testing.T) {
|
|
rtr, _, _, _ := newTestRouterAndEngine()
|
|
m := Model{
|
|
config: Config{
|
|
Router: rtr,
|
|
},
|
|
}
|
|
|
|
// Should match the default model
|
|
arm1 := m.findBestArmForProvider("openai")
|
|
if arm1 == nil {
|
|
t.Fatal("expected arm for openai")
|
|
}
|
|
if arm1.ModelName != "gpt-4o" {
|
|
t.Errorf("expected gpt-4o, got %s", arm1.ModelName)
|
|
}
|
|
|
|
// Should fallback to first arm if default model not found
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("unknown", "weird-model"),
|
|
Provider: security.WrapProvider(&mockProvider{name: "unknown", defaultModel: "missing"}, nil),
|
|
ModelName: "weird-model",
|
|
})
|
|
arm2 := m.findBestArmForProvider("unknown")
|
|
if arm2 == nil {
|
|
t.Fatal("expected arm for unknown")
|
|
}
|
|
if arm2.ModelName != "weird-model" {
|
|
t.Errorf("expected weird-model, got %s", arm2.ModelName)
|
|
}
|
|
}
|
|
|
|
func TestCloseAllPickersResetsProvider(t *testing.T) {
|
|
m := Model{providerPickerOpen: true}
|
|
m = m.closeAllPickers()
|
|
if m.providerPickerOpen {
|
|
t.Error("providerPickerOpen should be false after closeAllPickers")
|
|
}
|
|
}
|
|
|
|
func TestGetPickerItemCount_Provider(t *testing.T) {
|
|
rtr, _, _, _ := newTestRouterAndEngine()
|
|
m := Model{
|
|
providerPickerOpen: true,
|
|
config: Config{
|
|
Router: rtr,
|
|
},
|
|
}
|
|
count := m.getPickerItemCount()
|
|
if count != 2 {
|
|
t.Errorf("expected picker item count 2, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleProviderCommand_ArgsEmptyOpensPicker(t *testing.T) {
|
|
rtr, eng, _, _ := newTestRouterAndEngine()
|
|
sess := session.NewLocal(session.LocalConfig{
|
|
Engine: eng,
|
|
Provider: "anthropic",
|
|
Model: "claude-3-5-sonnet",
|
|
})
|
|
m := Model{
|
|
session: sess,
|
|
config: Config{
|
|
Router: rtr,
|
|
Engine: eng,
|
|
},
|
|
}
|
|
|
|
res, err := m.handleCommand("/provider")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
newM, ok := res.(Model)
|
|
if !ok {
|
|
t.Fatalf("expected Model type, got %T", res)
|
|
}
|
|
if !newM.providerPickerOpen {
|
|
t.Error("expected provider picker to be open")
|
|
}
|
|
}
|
|
|
|
func TestHandleProviderCommand_ArgsNotEmptySwitchesProvider(t *testing.T) {
|
|
rtr, eng, _, _ := newTestRouterAndEngine()
|
|
sess := session.NewLocal(session.LocalConfig{
|
|
Engine: eng,
|
|
Provider: "anthropic",
|
|
Model: "claude-3-5-sonnet",
|
|
})
|
|
m := Model{
|
|
session: sess,
|
|
config: Config{
|
|
Router: rtr,
|
|
Engine: eng,
|
|
},
|
|
}
|
|
|
|
res, err := m.handleCommand("/provider openai")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
newM, ok := res.(Model)
|
|
if !ok {
|
|
t.Fatalf("expected Model type, got %T", res)
|
|
}
|
|
if newM.providerPickerOpen {
|
|
t.Error("expected provider picker to be closed")
|
|
}
|
|
|
|
status := newM.session.Status()
|
|
if status.Provider != "openai" {
|
|
t.Errorf("expected provider to switch to openai, got %s", status.Provider)
|
|
}
|
|
if status.Model != "gpt-4o" {
|
|
t.Errorf("expected model to switch to gpt-4o, got %s", status.Model)
|
|
}
|
|
|
|
// Check messages contain switch system log
|
|
found := false
|
|
for _, msg := range newM.messages {
|
|
if msg.role == "system" && strings.Contains(msg.content, "provider switched to: openai") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected switch system message in history")
|
|
}
|
|
}
|
|
|
|
func TestConfigPanelTransitions(t *testing.T) {
|
|
rtr, eng, _, _ := newTestRouterAndEngine()
|
|
sess := session.NewLocal(session.LocalConfig{
|
|
Engine: eng,
|
|
Provider: "anthropic",
|
|
Model: "claude-3-5-sonnet",
|
|
})
|
|
m := Model{
|
|
session: sess,
|
|
configPanelOpen: true,
|
|
config: Config{
|
|
Router: rtr,
|
|
Engine: eng,
|
|
},
|
|
}
|
|
|
|
// 1. Select Provider (index 0)
|
|
m.configSelected = 0
|
|
m = m.applyConfigSetting()
|
|
if m.configPanelOpen {
|
|
t.Error("expected config panel to close when opening provider picker")
|
|
}
|
|
if !m.providerPickerOpen {
|
|
t.Error("expected provider picker to open")
|
|
}
|
|
|
|
// Reset state
|
|
m.configPanelOpen = true
|
|
m.providerPickerOpen = false
|
|
|
|
// 2. Select Model (index 1)
|
|
m.configSelected = 1
|
|
m = m.applyConfigSetting()
|
|
if m.configPanelOpen {
|
|
t.Error("expected config panel to close when opening model picker")
|
|
}
|
|
if !m.modelPickerOpen {
|
|
t.Error("expected model picker to open")
|
|
}
|
|
}
|
|
|
|
func TestConfigPanelTransitionsWithSLM(t *testing.T) {
|
|
rtr, eng, _, _ := newTestRouterAndEngine()
|
|
sess := session.NewLocal(session.LocalConfig{
|
|
Engine: eng,
|
|
Provider: "anthropic",
|
|
Model: "claude-3-5-sonnet",
|
|
})
|
|
m := Model{
|
|
session: sess,
|
|
configPanelOpen: true,
|
|
config: Config{
|
|
Router: rtr,
|
|
Engine: eng,
|
|
SLM: SLMInfo{
|
|
Active: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
// 1. Verify getActiveSettings only has permission and incognito
|
|
settings := m.getActiveSettings()
|
|
if len(settings) != 2 {
|
|
t.Fatalf("expected 2 settings when SLM is active, got %d", len(settings))
|
|
}
|
|
if settings[0] != "permission" || settings[1] != "incognito" {
|
|
t.Errorf("expected settings to be [permission, incognito], got %v", settings)
|
|
}
|
|
|
|
// 2. Try handling /model slash command — it should add a system message and not open picker
|
|
retM, _ := m.handleCommand("/model")
|
|
m2 := retM.(Model)
|
|
if m2.modelPickerOpen {
|
|
t.Error("expected model picker not to open when SLM is active")
|
|
}
|
|
if len(m2.messages) == 0 || m2.messages[len(m2.messages)-1].role != "system" {
|
|
t.Error("expected system warning message for blocked model switch")
|
|
}
|
|
|
|
// 3. Try handling /provider slash command — it should add a system message and not open picker
|
|
retP, _ := m.handleCommand("/provider")
|
|
m3 := retP.(Model)
|
|
if m3.providerPickerOpen {
|
|
t.Error("expected provider picker not to open when SLM is active")
|
|
}
|
|
if len(m3.messages) == 0 || m3.messages[len(m3.messages)-1].role != "system" {
|
|
t.Error("expected system warning message for blocked provider switch")
|
|
}
|
|
|
|
// 4. Verify rendering output mentions "router" instead of anthropic/claude-3-5-sonnet
|
|
statusStr := m.renderStatus()
|
|
if !strings.Contains(statusStr, "router") {
|
|
t.Errorf("expected status bar to contain 'router' when SLM is active, got: %q", statusStr)
|
|
}
|
|
if strings.Contains(statusStr, "anthropic") {
|
|
t.Errorf("expected status bar to hide 'anthropic' when SLM is active, got: %q", statusStr)
|
|
}
|
|
|
|
chatStr := m.renderChat(80)
|
|
if !strings.Contains(chatStr, "router (slm:") {
|
|
t.Errorf("expected header to contain 'router (slm:' when SLM is active, got: %q", chatStr)
|
|
}
|
|
if strings.Contains(chatStr, "anthropic") {
|
|
t.Errorf("expected header to hide 'anthropic' when SLM is active, got: %q", chatStr)
|
|
}
|
|
}
|