diff --git a/README.md b/README.md index cd5f406..e4b7e62 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,8 @@ mode = "last" # "last", "spec", or "history" debug = false expand-alias = true auto-execute = false +atuin-history = 0 +atuin-db-path = "" [ui] style = "modern" # "modern" or "classic" diff --git a/integration/history.go b/integration/history.go index 6fd4fc1..06771c5 100644 --- a/integration/history.go +++ b/integration/history.go @@ -2,6 +2,8 @@ package integration import ( "bufio" + "context" + "database/sql" "os" "path/filepath" "sort" @@ -11,6 +13,8 @@ import ( "github.com/versenilvis/fuzzy" "github.com/versenilvis/iris/integration/shell" + "github.com/versenilvis/iris/internal/config" + _ "modernc.org/sqlite" ) var ( @@ -19,9 +23,14 @@ var ( historyCache []string idMapCache map[string]int + sourceMapCache map[string]string searcherCache *fuzzy.Searcher mu sync.Mutex lastModTime int64 + + atuinCmds []string + atuinLastMod int64 + lastAtuinMode int = -1 ) func RecordSessionCommand(cmd string) { @@ -47,10 +56,12 @@ type HistResult struct { ID int Cmd string FuzzyScore int + Source string } func init() { idMapCache = make(map[string]int) + sourceMapCache = make(map[string]string) } func sanitizeUTF8(s string) string { @@ -68,6 +79,45 @@ func sanitizeUTF8(s string) string { return result.String() } +func loadAtuinCmds() ([]string, error) { + dbPath, err := config.AtuinDBPath() + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro") + if err != nil { + return nil, err + } + defer db.Close() + + rows, err := db.QueryContext(context.Background(), `SELECT command 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 + for rows.Next() { + var cmd string + if err := rows.Scan(&cmd); 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) + } + } + // reverse array to be oldest-first + 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() +} + func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) { mu.Lock() defer mu.Unlock() @@ -96,6 +146,27 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error } } + atuinMode := config.Get().Core.Atuin + if lastAtuinMode != -1 && atuinMode != lastAtuinMode { + historyCache = nil + } + lastAtuinMode = atuinMode + + if atuinMode > 0 { + dbPath, _ := config.AtuinDBPath() + if info, err := os.Stat(dbPath); err == nil { + mod := info.ModTime().UnixNano() + if mod != atuinLastMod { + atuinLastMod = mod + atuinCmds = nil + historyCache = nil + } + } + if atuinCmds == nil { + atuinCmds, _ = loadAtuinCmds() + } + } + if info, err := os.Stat(histFile); err == nil { if info.ModTime().UnixNano() > lastModTime { historyCache = nil // force reload @@ -115,53 +186,68 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error } var allCmds []string - if file != nil { - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - cmd := line + if atuinMode == 1 && len(atuinCmds) > 0 { + // atuin only — prepend newest-first so the merge loop below works + allCmds = atuinCmds + } else { + if file != nil { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + cmd := line - if shellName == "zsh" { - parts := strings.SplitN(line, ";", 2) - if len(parts) == 2 { - cmd = parts[1] - } - } else if shellName == "bash" { - if strings.HasPrefix(line, "#") && len(line) > 1 { - isTimestamp := true - for _, c := range line[1:] { - if c < '0' || c > '9' { - isTimestamp = false - break + if shellName == "zsh" { + parts := strings.SplitN(line, ";", 2) + if len(parts) == 2 { + cmd = parts[1] + } + } else if shellName == "bash" { + if strings.HasPrefix(line, "#") && len(line) > 1 { + isTimestamp := true + for _, c := range line[1:] { + if c < '0' || c > '9' { + isTimestamp = false + break + } + } + if isTimestamp { + continue } } - if isTimestamp { + } else if shellName == "fish" { + if after, ok := strings.CutPrefix(line, "- cmd: "); ok { + cmd = after + } else { continue } } - } else if shellName == "fish" { - if after, ok := strings.CutPrefix(line, "- cmd: "); ok { - cmd = after - } else { - continue + + cmd = strings.TrimSpace(cmd) + if cmd != "" { + cmd = sanitizeUTF8(cmd) + allCmds = append(allCmds, cmd) } } - - cmd = strings.TrimSpace(cmd) - if cmd != "" { - cmd = sanitizeUTF8(cmd) - allCmds = append(allCmds, cmd) + if scanner.Err() != nil { + _ = scanner.Err() } } - if err := scanner.Err(); err != nil { - return nil, err + // mode 2: append atuin (newer, higher priority) after shell file + if atuinMode == 2 && len(atuinCmds) > 0 { + allCmds = append(allCmds, atuinCmds...) } } // build historyCache backwards so newest commands come first seen := make(map[string]bool) + atuinSeen := make(map[string]bool) + for _, c := range atuinCmds { + atuinSeen[c] = true + } + historyCache = nil idMapCache = make(map[string]int) + sourceMapCache = make(map[string]string) currentID := len(sessionHistory) + len(allCmds) @@ -172,6 +258,11 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error historyCache = append(historyCache, cmd) seen[cmd] = true idMapCache[cmd] = currentID + if atuinSeen[cmd] { + sourceMapCache[cmd] = "atuin" + } else { + sourceMapCache[cmd] = "session" + } currentID-- } } @@ -183,6 +274,11 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error historyCache = append(historyCache, cmd) seen[cmd] = true idMapCache[cmd] = currentID + if atuinSeen[cmd] { + sourceMapCache[cmd] = "atuin" + } else { + sourceMapCache[cmd] = "history" + } currentID-- } } @@ -199,6 +295,7 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error results = append(results, HistResult{ ID: idMapCache[cmd], Cmd: cmd, + Source: sourceMapCache[cmd], }) } return results, nil @@ -264,6 +361,7 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error ID: idMapCache[cmd], Cmd: cmd, FuzzyScore: 10000, + Source: sourceMapCache[cmd], }) strictMatches++ if strictMatches >= 200 { @@ -288,6 +386,7 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error ID: idMapCache[m.Str], Cmd: m.Str, FuzzyScore: m.Score, + Source: sourceMapCache[m.Str], }) } } diff --git a/integration/history_atuin_test.go b/integration/history_atuin_test.go new file mode 100644 index 0000000..fd9b463 --- /dev/null +++ b/integration/history_atuin_test.go @@ -0,0 +1,60 @@ +package integration + +import ( + "testing" + "github.com/versenilvis/iris/internal/config" +) + +func TestSearchHistory_AtuinSourceMapping(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Core.Atuin = 2 + cfg.Core.AtuinDBPath = "/tmp/does-not-exist.db" + config.Init(cfg) + + sessionHistoryMu.Lock() + origSessionHistory := sessionHistory + sessionHistory = []string{"npm run build", "ls -l"} + sessionHistoryMu.Unlock() + + mu.Lock() + origHistoryCache := historyCache + origAtuinCmds := atuinCmds + historyCache = nil + atuinCmds = []string{"git push", "ls -l"} + mu.Unlock() + + t.Cleanup(func() { + sessionHistoryMu.Lock() + sessionHistory = origSessionHistory + sessionHistoryMu.Unlock() + + mu.Lock() + historyCache = origHistoryCache + atuinCmds = origAtuinCmds + mu.Unlock() + }) + + results, err := SearchHistory("", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + + sourceMap := make(map[string]string) + for _, r := range results { + sourceMap[r.Cmd] = r.Source + } + + if source := sourceMap["ls -l"]; source != "atuin" { + t.Errorf("expected 'ls -l' to have source 'atuin', got %q", source) + } + if source := sourceMap["npm run build"]; source != "session" { + t.Errorf("expected 'npm run build' to have source 'session', got %q", source) + } + if source := sourceMap["git push"]; source != "atuin" { + t.Errorf("expected 'git push' to have source 'atuin', got %q", source) + } +} diff --git a/integration/icons.go b/integration/icons.go index a6b9632..36a9dcb 100644 --- a/integration/icons.go +++ b/integration/icons.go @@ -123,6 +123,7 @@ var iconMap = map[string]string{ "unzip": "", "alias": "", "history": "", + "atuin": "󰳗", "system": "", "root": "", } diff --git a/integration/overlay.go b/integration/overlay.go index 5c29162..20f22ac 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -707,6 +707,15 @@ func (o *Overlay) draw() string { tw := lipgloss.Width(tag) rem := max(descW-tw-1, 0) desc = tag + bg.Render(" ") + bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth(it.Desc, rem)) + case "atuin": + boxStyle := lipgloss.NewStyle().Background(lipgloss.Color(t.Alias)).Foreground(lipgloss.Color(t.AliasSel)) + if selected { + boxStyle = lipgloss.NewStyle().Background(lipgloss.Color(t.AliasSel)).Foreground(lipgloss.Color(t.SelText)).Bold(true) + } + tag := boxStyle.Render(" atuin ") + tw := lipgloss.Width(tag) + rem := max(descW-tw-1, 0) + desc = tag + bg.Render(" ") + bg.Foreground(lipgloss.Color(descColor)).Render(fixedWidth("atuin history", rem)) case "history": boxStyle := lipgloss.NewStyle().Background(lipgloss.Color(t.History)).Foreground(lipgloss.Color(t.HistorySel)) if selected { diff --git a/internal/config/config.go b/internal/config/config.go index 68187c6..afcd78d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,9 @@ type CoreConfig struct { Debug bool `toml:"debug"` ExpandAlias bool `toml:"expand-alias"` AutoExecute bool `toml:"auto-execute"` + // 0 = shell history, 1 = atuin only, 2 = atuin + shell + Atuin int `toml:"atuin-history"` + AtuinDBPath string `toml:"atuin-db-path"` } type UIConfig struct { diff --git a/internal/config/paths.go b/internal/config/paths.go index f3e98df..4356d80 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -49,6 +49,21 @@ func HistoryDBPath() (string, error) { return filepath.Join(filepath.Dir(statePath), "history.db"), nil } +func AtuinDBPath() (string, error) { + if cfg := Get(); cfg != nil && cfg.Core.AtuinDBPath != "" { + return cfg.Core.AtuinDBPath, nil + } + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dataHome = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataHome, "atuin", "history.db"), nil +} + func CachePath() (string, error) { cacheHome := os.Getenv("XDG_CACHE_HOME") if cacheHome != "" { diff --git a/root/config_cmd.go b/root/config_cmd.go index 2e2d09d..27d0620 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -59,6 +59,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" diff --git a/root/suggestions.go b/root/suggestions.go index 8567ec1..e263bce 100644 --- a/root/suggestions.go +++ b/root/suggestions.go @@ -55,11 +55,17 @@ func MergeResults(query string, mode string) []spec.Suggestion { 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: "history", - Icon: "history", - Source: "history", + Desc: h.Source, + Icon: icon, + Source: h.Source, Confidence: conf, }) } diff --git a/root/wrapper.go b/root/wrapper.go index 6839f2d..761a9f5 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -304,6 +304,7 @@ func runWrapper() { shellPGID = spec.ShellPID } var isCommandActive atomic.Bool + var isAltScreenActive atomic.Bool var disableGhostText atomic.Bool disableGhostText.Store(!config.Get().UI.GhostText) var renderOverlayFn atomic.Value // holds func() @@ -315,6 +316,9 @@ func runWrapper() { } }) isExecuting := func() bool { + if isAltScreenActive.Load() { + return true + } if isCommandActive.Load() { // for bash: no preexec/precmd hooks, so fall back to TIOCGPGRP to detect when shell returns if shellName == "bash" { @@ -449,7 +453,17 @@ func runWrapper() { logger.Errorf("Unexpected PTY read error: %v", err) os.Exit(1) } - writeStdout(buf[:n]) + + // detect alternate screen buffer (smcup/rmcup) used by TUI apps (nvim, atuin, fzf) + chunk := buf[:n] + if bytes.Contains(chunk, []byte("\x1b[?1049h")) || bytes.Contains(chunk, []byte("\x1b[?1047h")) || bytes.Contains(chunk, []byte("\x1b[?47h")) { + isAltScreenActive.Store(true) + writeStdout([]byte(overlay.ClearAndDisable())) + } else if bytes.Contains(chunk, []byte("\x1b[?1049l")) || bytes.Contains(chunk, []byte("\x1b[?1047l")) || bytes.Contains(chunk, []byte("\x1b[?47l")) { + isAltScreenActive.Store(false) + } + + writeStdout(chunk) bufferMu.Lock() nbEmpty := naiveBuffer == ""