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).
191 lines
5.4 KiB
Go
191 lines
5.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestMatchCompletion(t *testing.T) {
|
|
cmds := []cmdEntry{
|
|
{"/clear", "clear history"},
|
|
{"/compact", "compact context"},
|
|
{"/config", "settings"},
|
|
{"/help", "show help"},
|
|
{"/model", "switch model"},
|
|
{"/permission", "set permission"},
|
|
{"/quit", "quit"},
|
|
}
|
|
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"/h", "/help"},
|
|
{"/he", "/help"},
|
|
{"/help", ""}, // already complete
|
|
{"/cl", "/clear"}, // unambiguous prefix
|
|
{"/co", ""}, // ambiguous: /compact, /config
|
|
{"/com", "/compact"},
|
|
{"/con", "/config"},
|
|
{"/q", "/quit"},
|
|
{"/model ", ""}, // has args — no command completion
|
|
{"hello", ""}, // not a slash command
|
|
{"/", ""}, // too short
|
|
{"/x", ""}, // no match
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := matchCompletion(tt.input, cmds, nil, nil)
|
|
if got != tt.want {
|
|
t.Errorf("matchCompletion(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFuzzyMatch(t *testing.T) {
|
|
tests := []struct {
|
|
pattern string
|
|
text string
|
|
want bool
|
|
}{
|
|
{"hlp", "help", true},
|
|
{"clr", "clear", true},
|
|
{"mdl", "model", true},
|
|
{"help", "help", true}, // exact match
|
|
{"HELP", "help", true}, // case insensitive
|
|
{"xyz", "help", false}, // no match
|
|
{"", "help", true}, // empty pattern matches everything
|
|
{"hx", "help", false}, // x not present
|
|
{"elp", "help", true}, // subsequence not at start
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := fuzzyMatch(tt.pattern, tt.text)
|
|
if got != tt.want {
|
|
t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.pattern, tt.text, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFuzzyMatchCommands(t *testing.T) {
|
|
cmds := []cmdEntry{
|
|
{"/clear", "clear history"},
|
|
{"/compact", "compact context"},
|
|
{"/config", "settings"},
|
|
{"/help", "show help"},
|
|
{"/model", "switch model"},
|
|
}
|
|
|
|
tests := []struct {
|
|
query string
|
|
wantLen int
|
|
wantFirst string
|
|
}{
|
|
{"", 5, "/clear"}, // empty = all commands
|
|
{"h", 1, "/help"}, // only /help contains h as subsequence
|
|
{"hel", 1, "/help"}, // only /help
|
|
{"mdl", 1, "/model"}, // subsequence match
|
|
{"xyz", 0, ""}, // no match
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := fuzzyMatchCommands(tt.query, cmds)
|
|
if len(got) != tt.wantLen {
|
|
t.Errorf("fuzzyMatchCommands(%q): got %d results, want %d (got: %v)", tt.query, len(got), tt.wantLen, got)
|
|
}
|
|
if tt.wantFirst != "" && len(got) > 0 && got[0].name != tt.wantFirst {
|
|
t.Errorf("fuzzyMatchCommands(%q): first result = %q, want %q", tt.query, got[0].name, tt.wantFirst)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchArgCompletion(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"/permission a", "/permission auto"},
|
|
{"/permission au", "/permission auto"},
|
|
{"/permission auto", ""}, // already complete
|
|
{"/permission d", "/permission default"}, // first match
|
|
{"/perm b", "/perm bypass"},
|
|
{"/perm p", "/perm plan"},
|
|
{"/model foo", ""}, // no arg completion for /model yet
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := matchArgCompletion(tt.input, nil, nil)
|
|
if got != tt.want {
|
|
t.Errorf("matchArgCompletion(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchArgCompletion_Profile(t *testing.T) {
|
|
profiles := []string{"experiment", "private", "work"}
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"/profile w", "/profile work"},
|
|
{"/profile p", "/profile private"},
|
|
{"/profile work", ""}, // already complete
|
|
{"/profile e", "/profile experiment"},
|
|
{"/profile z", ""}, // no match
|
|
{"/profile ", ""}, // empty arg — wait for input
|
|
}
|
|
for _, tt := range tests {
|
|
got := matchArgCompletion(tt.input, profiles, nil)
|
|
if got != tt.want {
|
|
t.Errorf("matchArgCompletion(%q, profiles) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchCompletion_DispatchesToProfileArgCompletion(t *testing.T) {
|
|
// End-to-end: matchCompletion sees "/profile w", forwards to
|
|
// matchArgCompletion with profileNames, gets back "/profile work".
|
|
cmds := []cmdEntry{{"/profile", "profiles"}}
|
|
got := matchCompletion("/profile w", cmds, []string{"work", "private"}, nil)
|
|
if got != "/profile work" {
|
|
t.Errorf("matchCompletion(/profile w) = %q, want /profile work", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchArgCompletion_ProfileNoNamesAvailable(t *testing.T) {
|
|
// When profile mode isn't engaged, profileNames is nil/empty and the
|
|
// completer must not try to suggest anything.
|
|
got := matchArgCompletion("/profile w", nil, nil)
|
|
if got != "" {
|
|
t.Errorf("matchArgCompletion(profile, nil) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestMatchArgCompletion_Provider(t *testing.T) {
|
|
providers := []string{"anthropic", "openai", "google"}
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"/provider a", "/provider anthropic"},
|
|
{"/provider o", "/provider openai"},
|
|
{"/provider openai", ""}, // already complete
|
|
{"/provider g", "/provider google"},
|
|
{"/provider z", ""}, // no match
|
|
{"/provider ", ""}, // empty arg — wait for input
|
|
}
|
|
for _, tt := range tests {
|
|
got := matchArgCompletion(tt.input, nil, providers)
|
|
if got != tt.want {
|
|
t.Errorf("matchArgCompletion(%q, providers) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchCompletion_DispatchesToProviderArgCompletion(t *testing.T) {
|
|
cmds := []cmdEntry{{"/provider", "providers"}}
|
|
got := matchCompletion("/provider a", cmds, nil, []string{"anthropic", "openai"})
|
|
if got != "/provider anthropic" {
|
|
t.Errorf("matchCompletion(/provider a) = %q, want /provider anthropic", got)
|
|
}
|
|
}
|