feat(history): atuin history support (#134)
closes #91 3 mode config atuin history ```toml [core] # 0 = off, 1 = atuin history only, 2 = atuin history + default history atuin-history = 0 ``` Default is Mode 0 (turn off), who uses atuin history has to config it manually Mode 1 = atuin only (as you can see only 11 commands): <img width="1285" height="466" alt="image" src="https://github.com/user-attachments/assets/ca520ec7-fb45-4403-8087-a4951ba8dba0" /> Mode 2 = combine shell history + atuin history (prefer atuin): <img width="1261" height="469" alt="image" src="https://github.com/user-attachments/assets/1558ad3e-38f3-4d6e-bc8e-cfc00ea9cb4b" /> Mode 0 = turn off atuin --- Custom atuin db path if anyone's using their own path (leave empty to use default from atuin) ```toml atuin-db-path = "" ``` --- I also handled the search menu overlay of Atuin, IRIS won't show in this mode, but you have to change the keybind between 2 tool cuz they're using same keybind Ctrl + R, but I think using IRIS make it unnecessary to use Atuin search mode (82b180c9610f75d41008a54dfde474c7f2cce015) <img width="70%" alt="image" src="https://github.com/user-attachments/assets/e0c62050-7e6e-4e37-a1cc-2bfb63e1e984" /> <i><b>Before (Menu layout completely broken)</b></i> <img width="50%" alt="image" src="https://github.com/user-attachments/assets/1e6d8de9-cde8-473d-8a42-4748c480c812" /> <i ><b>After handling (not shows anymore)</b></i>
This commit is contained in:
@@ -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"
|
||||
|
||||
+129
-30
@@ -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],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ var iconMap = map[string]string{
|
||||
"unzip": "",
|
||||
"alias": "",
|
||||
"history": "",
|
||||
"atuin": "",
|
||||
"system": "",
|
||||
"root": "",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+9
-3
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+15
-1
@@ -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 == ""
|
||||
|
||||
Reference in New Issue
Block a user