From e897b4a89a278b8c222bd91ed448faf3ad98d8fe Mon Sep 17 00:00:00 2001 From: Pavel Sinitsin Date: Fri, 14 Aug 2026 13:30:10 +0300 Subject: [PATCH] feat(history): add Atuin context scopes --- README.md | 3 + integration/history.go | 256 +++++++++++++++++++--- integration/history_atuin_context_test.go | 229 +++++++++++++++++++ integration/history_atuin_test.go | 10 + internal/config/config.go | 11 + internal/config/config_test.go | 18 ++ internal/config/defaults.go | 3 + internal/workspace/workspace.go | 15 ++ internal/workspace/workspace_test.go | 20 +- root/config_cmd.go | 4 + root/config_cmd_test.go | 3 + root/init.go | 10 + root/suggestions.go | 8 +- 13 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 integration/history_atuin_context_test.go diff --git a/README.md b/README.md index 424e2cb..7bfcded 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,9 @@ navigate-right = "right" # accept ghost text filter-active-branch = true # exclude current branch from suggestions deduplicate-branches = true # merge same-name local/remote branches +[history] +scope = "global" # "global", "directory", "workspace", or "hybrid" + [updater] check-on-startup = true # check for updates on startup channel = "stable" # "stable" or "nightly" diff --git a/integration/history.go b/integration/history.go index 06771c5..635d592 100644 --- a/integration/history.go +++ b/integration/history.go @@ -9,11 +9,13 @@ import ( "sort" "strings" "sync" + "time" "unicode/utf8" "github.com/versenilvis/fuzzy" "github.com/versenilvis/iris/integration/shell" "github.com/versenilvis/iris/internal/config" + "github.com/versenilvis/iris/internal/workspace" _ "modernc.org/sqlite" ) @@ -21,18 +23,28 @@ var ( sessionHistory []string sessionHistoryMu sync.Mutex - historyCache []string - idMapCache map[string]int + historyCache []string + idMapCache map[string]int sourceMapCache map[string]string - searcherCache *fuzzy.Searcher - mu sync.Mutex - lastModTime int64 + searcherCache *fuzzy.Searcher + mu sync.Mutex + lastModTime int64 - atuinCmds []string - atuinLastMod int64 + atuinCmds []string + atuinEntries []atuinEntry + atuinLastMod int64 lastAtuinMode int = -1 ) +type atuinEntry struct { + Command string + CWD string + Timestamp int64 + Exit int + Session string + Hostname string +} + func RecordSessionCommand(cmd string) { cmd = strings.TrimSpace(cmd) if cmd == "" { @@ -79,7 +91,7 @@ func sanitizeUTF8(s string) string { return result.String() } -func loadAtuinCmds() ([]string, error) { +func loadAtuinEntries() ([]atuinEntry, error) { dbPath, err := config.AtuinDBPath() if err != nil { return nil, err @@ -89,36 +101,69 @@ func loadAtuinCmds() ([]string, error) { return nil, err } defer db.Close() + db.SetMaxOpenConns(1) - rows, err := db.QueryContext(context.Background(), `SELECT command FROM history WHERE deleted_at IS NULL ORDER BY timestamp DESC LIMIT 10000`) + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + rows, err := db.QueryContext(ctx, ` +SELECT command, cwd, timestamp, exit, session, hostname +FROM history +WHERE deleted_at IS NULL +ORDER BY timestamp DESC +LIMIT 10000`) if err != nil { return nil, err } defer rows.Close() - seen := make(map[string]bool) - var cmds []string + var entries []atuinEntry for rows.Next() { - var cmd string - if err := rows.Scan(&cmd); err != nil { + var entry atuinEntry + if err := rows.Scan( + &entry.Command, + &entry.CWD, + &entry.Timestamp, + &entry.Exit, + &entry.Session, + &entry.Hostname, + ); err != nil { continue } - cmd = strings.TrimSpace(sanitizeUTF8(cmd)) - cmd = strings.ReplaceAll(cmd, "\n", " ") - cmd = strings.ReplaceAll(cmd, "\r", "") - if cmd != "" && !seen[cmd] { - seen[cmd] = true - cmds = append(cmds, cmd) + entry.Command = strings.TrimSpace(sanitizeUTF8(entry.Command)) + entry.Command = strings.ReplaceAll(entry.Command, "\n", " ") + entry.Command = strings.ReplaceAll(entry.Command, "\r", "") + entry.CWD = sanitizeUTF8(entry.CWD) + if entry.Command != "" { + entries = append(entries, entry) } } - // reverse array to be oldest-first + return entries, rows.Err() +} + +func atuinCommands(entries []atuinEntry) []string { + seen := make(map[string]bool, len(entries)) + cmds := make([]string, 0, len(entries)) + for _, entry := range entries { + if !seen[entry.Command] { + seen[entry.Command] = true + cmds = append(cmds, entry.Command) + } + } + + // Existing history merge expects oldest-first input. for i, j := 0, len(cmds)-1; i < j; i, j = i+1, j-1 { cmds[i], cmds[j] = cmds[j], cmds[i] } - return cmds, rows.Err() + return cmds } func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) { + return SearchHistoryIn(query, aliases, "") +} + +// SearchHistoryIn applies configured Atuin context to a non-empty query. +func SearchHistoryIn(query string, aliases map[string]string, cwd string) ([]HistResult, error) { mu.Lock() defer mu.Unlock() @@ -159,11 +204,13 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error if mod != atuinLastMod { atuinLastMod = mod atuinCmds = nil + atuinEntries = nil historyCache = nil } } if atuinCmds == nil { - atuinCmds, _ = loadAtuinCmds() + atuinEntries, _ = loadAtuinEntries() + atuinCmds = atuinCommands(atuinEntries) } } @@ -293,8 +340,8 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error for i := range limit { cmd := historyCache[i] results = append(results, HistResult{ - ID: idMapCache[cmd], - Cmd: cmd, + ID: idMapCache[cmd], + Cmd: cmd, Source: sourceMapCache[cmd], }) } @@ -439,5 +486,164 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error return results[i].ID > results[j].ID }) - return results, nil + return applyAtuinScope(results, query, aliases, cwd), nil +} + +func applyAtuinScope(global []HistResult, query string, aliases map[string]string, cwd string) []HistResult { + scope := config.Get().History.Scope + if query == "" || cwd == "" || config.Get().Core.Atuin == 0 || scope == "global" { + return global + } + + directory, repository := contextualAtuinCommands(atuinEntries, cwd) + queries := historyQueries(query, aliases) + directoryMatches := matchContextCommands(directory, queries) + repositoryMatches := matchContextCommands(repository, queries) + + switch scope { + case "directory": + return directoryMatches + case "workspace": + if workspace.GitRoot(cwd) == "" { + return directoryMatches + } + return mergeHistoryResults(directoryMatches, repositoryMatches) + case "hybrid": + contextual := mergeHistoryResults(directoryMatches, repositoryMatches) + return mergeHistoryResults(contextual, global) + default: + return global + } +} + +func contextualAtuinCommands(entries []atuinEntry, cwd string) (directory, repository []string) { + cwd = filepath.Clean(cwd) + root := workspace.GitRoot(cwd) + seenDirectory := make(map[string]bool) + seenRepository := make(map[string]bool) + + for _, entry := range entries { + entryCWD := filepath.Clean(entry.CWD) + if entry.CWD == "" || entry.CWD == "unknown" || !filepath.IsAbs(entryCWD) { + continue + } + if entryCWD == cwd { + if !seenDirectory[entry.Command] { + directory = append(directory, entry.Command) + seenDirectory[entry.Command] = true + } + continue + } + if root != "" && pathWithin(root, entryCWD) && !seenRepository[entry.Command] { + repository = append(repository, entry.Command) + seenRepository[entry.Command] = true + } + } + + return directory, repository +} + +func pathWithin(root, path string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +func historyQueries(query string, aliases map[string]string) []string { + queries := []string{query} + qLow := strings.ToLower(query) + for name, target := range aliases { + if target == "" { + continue + } + tLow := strings.ToLower(target) + nLow := strings.ToLower(name) + if qLow == tLow { + queries = append(queries, name) + } else if strings.HasPrefix(qLow, tLow+" ") { + queries = append(queries, name+query[len(target):]) + } + if qLow == nLow { + queries = append(queries, target) + } else if strings.HasPrefix(qLow, nLow+" ") { + queries = append(queries, target+query[len(name):]) + } + } + return queries +} + +func matchContextCommands(commands, queries []string) []HistResult { + if len(commands) == 0 || len(queries) == 0 { + return nil + } + + results := make([]HistResult, 0, min(len(commands), 100)) + seen := make(map[string]bool, len(commands)) + for tier := 1; tier <= 3; tier++ { + for i, cmd := range commands { + if seen[cmd] || bestHistoryTier(cmd, queries) != tier { + continue + } + seen[cmd] = true + results = append(results, HistResult{ID: len(commands) - i, Cmd: cmd, FuzzyScore: 10000, Source: "atuin"}) + } + } + + searcher := fuzzy.NewPlainSearcher(commands) + for _, query := range queries { + matches := searcher.SearchWithScores(query, &fuzzy.SearchOptions{Limit: min(len(commands), 200)}) + for _, match := range matches { + if seen[match.Str] || len(query) == 0 || match.Score/len(query) < 150 { + continue + } + seen[match.Str] = true + results = append(results, HistResult{Cmd: match.Str, FuzzyScore: match.Score, Source: "atuin"}) + } + } + + return results +} + +func bestHistoryTier(cmd string, queries []string) int { + cmdLow := strings.ToLower(cmd) + best := 4 + for _, query := range queries { + queryLow := strings.ToLower(query) + switch { + case cmdLow == queryLow: + return 1 + case strings.HasPrefix(cmdLow, queryLow): + best = min(best, 2) + case containsAllWords(cmdLow, queryLow): + best = min(best, 3) + } + } + return best +} + +func containsAllWords(cmd, query string) bool { + words := strings.Fields(query) + if len(words) == 0 { + return false + } + for _, word := range words { + if !strings.Contains(cmd, word) { + return false + } + } + return true +} + +func mergeHistoryResults(groups ...[]HistResult) []HistResult { + seen := make(map[string]bool) + var results []HistResult + for _, group := range groups { + for _, result := range group { + if seen[result.Cmd] { + continue + } + seen[result.Cmd] = true + results = append(results, result) + } + } + return results } diff --git a/integration/history_atuin_context_test.go b/integration/history_atuin_context_test.go new file mode 100644 index 0000000..3790e24 --- /dev/null +++ b/integration/history_atuin_context_test.go @@ -0,0 +1,229 @@ +package integration + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/versenilvis/iris/internal/config" +) + +func TestLoadAtuinEntriesPreservesContext(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "history.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE history ( +command TEXT NOT NULL, +cwd TEXT NOT NULL, +timestamp INTEGER NOT NULL, +exit INTEGER NOT NULL, +session TEXT NOT NULL, +hostname TEXT NOT NULL, +deleted_at INTEGER +)`) + if err != nil { + t.Fatal(err) + } + rows := []struct { + command, cwd, session, hostname string + timestamp, exit, deletedAt int + }{ + {"old command", "/repo", "s1", "host", 10, 0, 0}, + {"new\ncommand", "/repo/pkg", "s2", "host", 30, 1, 0}, + {"deleted command", "/repo", "s3", "host", 40, 0, 99}, + } + for _, row := range rows { + var deletedAt any + if row.deletedAt != 0 { + deletedAt = row.deletedAt + } + _, err = db.Exec( + "INSERT INTO history VALUES (?, ?, ?, ?, ?, ?, ?)", + row.command, row.cwd, row.timestamp, row.exit, row.session, row.hostname, deletedAt, + ) + if err != nil { + t.Fatal(err) + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + originalConfig := config.Get() + cfg := config.DefaultConfig() + cfg.Core.AtuinDBPath = dbPath + config.Init(cfg) + t.Cleanup(func() { config.Init(originalConfig) }) + + entries, err := loadAtuinEntries() + if err != nil { + t.Fatalf("load Atuin entries: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected two active entries, got %d", len(entries)) + } + if entries[0].Command != "new command" || entries[0].CWD != "/repo/pkg" || entries[0].Exit != 1 { + t.Fatalf("unexpected newest entry: %#v", entries[0]) + } + if entries[1].Command != "old command" || entries[1].Session != "s1" { + t.Fatalf("unexpected oldest entry: %#v", entries[1]) + } +} + +func TestApplyAtuinScope(t *testing.T) { + repo := t.TempDir() + cwd := filepath.Join(repo, "internal", "pkg") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cwd, 0755); err != nil { + t.Fatal(err) + } + + originalConfig := config.Get() + originalEntries := atuinEntries + t.Cleanup(func() { + config.Init(originalConfig) + atuinEntries = originalEntries + }) + + cfg := config.DefaultConfig() + cfg.Core.Atuin = 1 + config.Init(cfg) + atuinEntries = []atuinEntry{ + {Command: "git status", CWD: cwd, Timestamp: 50}, + {Command: "git pull", CWD: repo, Timestamp: 40}, + {Command: "git status", CWD: cwd, Timestamp: 30}, + {Command: "git push", CWD: filepath.Join(t.TempDir(), "other"), Timestamp: 20}, + {Command: "git unknown", CWD: "unknown", Timestamp: 10}, + } + global := []HistResult{ + {Cmd: "git push", Source: "atuin"}, + {Cmd: "git pull", Source: "atuin"}, + {Cmd: "git status", Source: "atuin"}, + } + + tests := []struct { + scope string + want []string + }{ + {"global", []string{"git push", "git pull", "git status"}}, + {"directory", []string{"git status"}}, + {"workspace", []string{"git status", "git pull"}}, + {"hybrid", []string{"git status", "git pull", "git push"}}, + } + for _, tt := range tests { + t.Run(tt.scope, func(t *testing.T) { + cfg.History.Scope = tt.scope + got := resultCommands(applyAtuinScope(global, "g", map[string]string{"g": "git"}, cwd)) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("scope %s: got %v, want %v", tt.scope, got, tt.want) + } + }) + } + + cfg.History.Scope = "hybrid" + if got := resultCommands(applyAtuinScope(global, "", nil, cwd)); !reflect.DeepEqual(got, resultCommands(global)) { + t.Fatalf("empty query must stay global: got %v", got) + } +} + +func TestWorkspaceScopeOutsideGitUsesDirectory(t *testing.T) { + cwd := t.TempDir() + originalConfig := config.Get() + originalEntries := atuinEntries + t.Cleanup(func() { + config.Init(originalConfig) + atuinEntries = originalEntries + }) + + cfg := config.DefaultConfig() + cfg.Core.Atuin = 1 + cfg.History.Scope = "workspace" + config.Init(cfg) + atuinEntries = []atuinEntry{ + {Command: "make local", CWD: cwd}, + {Command: "make elsewhere", CWD: t.TempDir()}, + } + + got := resultCommands(applyAtuinScope(nil, "make", nil, cwd)) + if !reflect.DeepEqual(got, []string{"make local"}) { + t.Fatalf("got %v, want directory-only result", got) + } +} + +func BenchmarkContextualAtuinCommands(b *testing.B) { + repo := b.TempDir() + cwd := filepath.Join(repo, "internal", "pkg") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + b.Fatal(err) + } + if err := os.MkdirAll(cwd, 0755); err != nil { + b.Fatal(err) + } + entries := make([]atuinEntry, 10000) + for i := range entries { + entryCWD := repo + if i%3 == 0 { + entryCWD = cwd + } + entries[i] = atuinEntry{Command: fmt.Sprintf("command %d", i), CWD: entryCWD} + } + + b.ResetTimer() + for range b.N { + contextualAtuinCommands(entries, cwd) + } +} + +func BenchmarkApplyAtuinScope(b *testing.B) { + repo := b.TempDir() + cwd := filepath.Join(repo, "internal", "pkg") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + b.Fatal(err) + } + if err := os.MkdirAll(cwd, 0755); err != nil { + b.Fatal(err) + } + + originalConfig := config.Get() + originalEntries := atuinEntries + b.Cleanup(func() { + config.Init(originalConfig) + atuinEntries = originalEntries + }) + cfg := config.DefaultConfig() + cfg.Core.Atuin = 1 + cfg.History.Scope = "hybrid" + config.Init(cfg) + + atuinEntries = make([]atuinEntry, 10000) + global := make([]HistResult, 10000) + for i := range atuinEntries { + entryCWD := repo + if i%3 == 0 { + entryCWD = cwd + } + command := fmt.Sprintf("command %d", i) + atuinEntries[i] = atuinEntry{Command: command, CWD: entryCWD} + global[i] = HistResult{Cmd: command, Source: "atuin"} + } + + b.ResetTimer() + for range b.N { + applyAtuinScope(global, "command 999", nil, cwd) + } +} + +func resultCommands(results []HistResult) []string { + commands := make([]string, len(results)) + for i, result := range results { + commands[i] = result.Cmd + } + return commands +} diff --git a/integration/history_atuin_test.go b/integration/history_atuin_test.go index fd9b463..a990807 100644 --- a/integration/history_atuin_test.go +++ b/integration/history_atuin_test.go @@ -2,10 +2,12 @@ package integration import ( "testing" + "github.com/versenilvis/iris/internal/config" ) func TestSearchHistory_AtuinSourceMapping(t *testing.T) { + originalConfig := config.Get() cfg := config.DefaultConfig() cfg.Core.Atuin = 2 cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db" @@ -19,11 +21,16 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) { mu.Lock() origHistoryCache := historyCache origAtuinCmds := atuinCmds + origAtuinEntries := atuinEntries + origAtuinLastMod := atuinLastMod + origLastAtuinMode := lastAtuinMode historyCache = nil atuinCmds = []string{"git push", "ls -l"} mu.Unlock() t.Cleanup(func() { + config.Init(originalConfig) + sessionHistoryMu.Lock() sessionHistory = origSessionHistory sessionHistoryMu.Unlock() @@ -31,6 +38,9 @@ func TestSearchHistory_AtuinSourceMapping(t *testing.T) { mu.Lock() historyCache = origHistoryCache atuinCmds = origAtuinCmds + atuinEntries = origAtuinEntries + atuinLastMod = origAtuinLastMod + lastAtuinMode = origLastAtuinMode mu.Unlock() }) diff --git a/internal/config/config.go b/internal/config/config.go index d3b91d8..ea143a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -78,6 +78,11 @@ type KeybindingsConfig struct { NavigateRight string `toml:"navigate-right"` } +type HistoryConfig struct { + // Scope controls Atuin filtering for non-empty history searches. + Scope string `toml:"scope"` +} + type SuggestOnEmptyConfig struct { Enabled bool `toml:"enabled"` DebounceMS int `toml:"debounce_ms"` @@ -125,6 +130,7 @@ type Config struct { Core CoreConfig `toml:"core"` UI UIConfig `toml:"ui"` Git GitConfig `toml:"git"` + History HistoryConfig `toml:"history"` Updater UpdaterConfig `toml:"updater"` AI AIConfig `toml:"ai"` Keybindings KeybindingsConfig `toml:"keybindings"` @@ -282,6 +288,11 @@ func validate(cfg *Config) error { return fmt.Errorf("core.shell: invalid value %q (want: bash|zsh|fish)", cfg.Core.Shell) } + validHistoryScopes := map[string]bool{"global": true, "directory": true, "workspace": true, "hybrid": true} + if !validHistoryScopes[cfg.History.Scope] { + return fmt.Errorf("history.scope: invalid value %q (want: global|directory|workspace|hybrid)", cfg.History.Scope) + } + validChannels := map[string]bool{"stable": true, "nightly": true} if !validChannels[cfg.Updater.Channel] { return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4e9c1f1..830ec1f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,6 +30,9 @@ func TestDefaultConfigAndState(t *testing.T) { if !cfg.Core.CobraProbeEnabled { t.Errorf("expected cobra probing to be enabled by default") } + if cfg.History.Scope != "global" { + t.Errorf("expected global history scope, got %q", cfg.History.Scope) + } // test manual provider registration cfg.AI.Provider = "custom" @@ -207,6 +210,21 @@ func TestValidateAutoUpdateRange(t *testing.T) { } } +func TestValidateHistoryScope(t *testing.T) { + cfg := DefaultConfig() + for _, scope := range []string{"global", "directory", "workspace", "hybrid"} { + cfg.History.Scope = scope + if err := validate(cfg); err != nil { + t.Errorf("expected history scope %q to be valid: %v", scope, err) + } + } + + cfg.History.Scope = "session" + if err := validate(cfg); err == nil { + t.Error("expected unsupported history scope to be rejected") + } +} + func TestLoadSave(t *testing.T) { tmpDir, err := os.MkdirTemp("", "iris-config-test") if err != nil { diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 695c3e4..09d5722 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -29,6 +29,9 @@ func DefaultConfig() *Config { FilterActiveBranch: true, DeduplicateBranches: true, }, + History: HistoryConfig{ + Scope: "global", + }, Updater: UpdaterConfig{ CheckOnStartup: true, Channel: "stable", diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 7b7b006..bd66123 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -108,6 +108,21 @@ func detectGitInfo(cwd string) (hasGit bool, branch string) { return hasGit, "" } +// GitRoot returns the repository root containing cwd, or an empty string. +func GitRoot(cwd string) string { + dir := filepath.Clean(cwd) + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + type cacheEntry struct { key string // cwd + "|" + dirModTime info WorkspaceInfo diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index 231c651..2609043 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -44,6 +44,24 @@ func TestDetect_EmptyDirectory(t *testing.T) { } } +func TestGitRoot(t *testing.T) { + repo := t.TempDir() + nested := filepath.Join(repo, "internal", "pkg") + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + + if got := GitRoot(nested); got != repo { + t.Fatalf("expected git root %q, got %q", repo, got) + } + if got := GitRoot(t.TempDir()); got != "" { + t.Fatalf("expected no git root, got %q", got) + } +} + func TestDetect_NodeProject(t *testing.T) { tmp := t.TempDir() _ = os.WriteFile(filepath.Join(tmp, "package.json"), []byte("{}"), 0644) @@ -135,7 +153,7 @@ func TestDetectCached_BranchSwitchWithoutDirChange(t *testing.T) { // Simulate branch switch by updating .git/HEAD only // (this does not update the modtime of tmp on typical filesystems since tmp's direct children didn't change) _ = os.WriteFile(headPath, []byte("ref: refs/heads/feature\n"), 0644) - + // Force the modtime of HEAD to be distinct to avoid flakiness on low-res file systems infoAfter, _ := os.Stat(headPath) newMod := infoAfter.ModTime().Add(2 * time.Second) diff --git a/root/config_cmd.go b/root/config_cmd.go index 11308b7..a768817 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -97,6 +97,10 @@ filter-active-branch = true # merge remote and local branches with same name deduplicate-branches = true +[history] +# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid" +scope = "global" + [updater] # check for updates on startup check-on-startup = true diff --git a/root/config_cmd_test.go b/root/config_cmd_test.go index 7755ee9..84d6825 100644 --- a/root/config_cmd_test.go +++ b/root/config_cmd_test.go @@ -37,6 +37,9 @@ func TestConfigCommands(t *testing.T) { if !strings.Contains(string(content), "shell-login = false") { t.Error("expected initialized config to include shell-login = false") } + if !strings.Contains(string(content), "scope = \"global\"") { + t.Error("expected initialized config to include global history scope") + } buf := new(bytes.Buffer) ConfigShowCmd.SetOut(buf) diff --git a/root/init.go b/root/init.go index 3916d30..a299fa9 100644 --- a/root/init.go +++ b/root/init.go @@ -247,6 +247,12 @@ expand-alias = true # automatically execute command after accepting suggestion auto-execute = false +# 0 = off, 1 = atuin history only, 2 = atuin history + default history +atuin-history = 0 + +# custom atuin database path (leave empty for default) +atuin-db-path = "" + [ui] # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) style = "modern" @@ -276,6 +282,10 @@ filter-active-branch = true # merge remote and local branches with same name deduplicate-branches = true +[history] +# atuin scope for typed queries: "global", "directory", "workspace", or "hybrid" +scope = "global" + [updater] # check for updates on startup check-on-startup = true diff --git a/root/suggestions.go b/root/suggestions.go index e263bce..499941a 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -49,18 +49,18 @@ func MergeResults(query string, mode string) []spec.Suggestion { if mode == "history" { aliases := spec.GetAliasesCopy() - histResults, _ := integration.SearchHistory(query, aliases) + histResults, _ := integration.SearchHistoryIn(query, aliases, spec.GetCWD()) // scale confidence based on recency (index in histResults) so the most recent commands stay on top baseConf := 75 for i, h := range histResults { conf := max(baseConf-(i*2), 60) - + icon := "history" if h.Source == "atuin" { icon = "atuin" } - + addSuggestion(spec.Suggestion{ Cmd: h.Cmd, Desc: h.Source, @@ -97,7 +97,7 @@ func MergeResults(query string, mode string) []spec.Suggestion { if len(tokens) > 0 { rootCmd = tokens[0] } - + ctxTimeout, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() store, _ := scoring.GetFrecencyStore()