feat(scoring): transition workflow learning and fix history UX (#39)
- add transition scoring engine to learn sequential workflows per directory using command skeletons - prioritize current active git branch over older branches in suggestions - restore strict chronological order for history navigation and bypass AI re-ranking - add in-memory session history for instant access to just-run commands - fix PTY prompt not syncing when using arrow keys in the history menu - fix history tie-breaker to properly prioritize newer commands by assigning larger IDs - fix potential mutex deadlock between bufferMu and pty write during history navigation - fix workspace git detection to remove depth limits and correctly identify repositories with detached HEADs - fix SQLite connection leaks in frecency queries - fix global state leaks in history tests by snapshotting and restoring registry states - modernize string prefix checks using strings.CutPrefix
This commit is contained in:
+75
-33
@@ -13,6 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
sessionHistory []string
|
||||
sessionHistoryMu sync.Mutex
|
||||
|
||||
historyCache []string
|
||||
idMapCache map[string]int
|
||||
searcherCache *fuzzy.Searcher
|
||||
@@ -20,6 +23,24 @@ var (
|
||||
lastModTime int64
|
||||
)
|
||||
|
||||
func RecordSessionCommand(cmd string) {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd == "" {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
sessionHistoryMu.Lock()
|
||||
defer sessionHistoryMu.Unlock()
|
||||
|
||||
if len(sessionHistory) > 0 && sessionHistory[len(sessionHistory)-1] == cmd {
|
||||
return
|
||||
}
|
||||
sessionHistory = append(sessionHistory, cmd)
|
||||
historyCache = nil // invalidate to merge session history on next search
|
||||
}
|
||||
|
||||
type HistResult struct {
|
||||
ID int
|
||||
Cmd string
|
||||
@@ -65,61 +86,82 @@ func SearchHistory(query string, aliases map[string]string) ([]HistResult, error
|
||||
// lazy load history if cache is empty
|
||||
if len(historyCache) == 0 {
|
||||
file, err := os.Open(histFile)
|
||||
if err != nil {
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
if file != nil {
|
||||
defer func() { _ = file.Close() }()
|
||||
}
|
||||
|
||||
var allCmds []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
cmd := line
|
||||
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 != "" {
|
||||
allCmds = append(allCmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" {
|
||||
allCmds = append(allCmds, cmd)
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// build historyCache backwards so newest commands come first
|
||||
seen := make(map[string]bool)
|
||||
historyCache = nil
|
||||
idMapCache = make(map[string]int)
|
||||
|
||||
currentID := len(sessionHistory) + len(allCmds)
|
||||
|
||||
sessionHistoryMu.Lock()
|
||||
for i := len(sessionHistory) - 1; i >= 0; i-- {
|
||||
cmd := sessionHistory[i]
|
||||
if !seen[cmd] {
|
||||
historyCache = append(historyCache, cmd)
|
||||
seen[cmd] = true
|
||||
idMapCache[cmd] = currentID
|
||||
currentID--
|
||||
}
|
||||
}
|
||||
sessionHistoryMu.Unlock()
|
||||
|
||||
for i := len(allCmds) - 1; i >= 0; i-- {
|
||||
cmd := allCmds[i]
|
||||
if !seen[cmd] {
|
||||
historyCache = append(historyCache, cmd)
|
||||
seen[cmd] = true
|
||||
// we assign the ID as the original line number (1-indexed based on allCmds length)
|
||||
idMapCache[cmd] = i + 1
|
||||
idMapCache[cmd] = currentID
|
||||
currentID--
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecordSessionCommand_MergeAndDeduplicate(t *testing.T) {
|
||||
sessionHistoryMu.Lock()
|
||||
origSessionHistory := sessionHistory
|
||||
sessionHistory = nil
|
||||
sessionHistoryMu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
origHistoryCache := historyCache
|
||||
historyCache = nil
|
||||
mu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
sessionHistoryMu.Lock()
|
||||
sessionHistory = origSessionHistory
|
||||
sessionHistoryMu.Unlock()
|
||||
|
||||
mu.Lock()
|
||||
historyCache = origHistoryCache
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
RecordSessionCommand("git status")
|
||||
RecordSessionCommand("npm run dev")
|
||||
RecordSessionCommand("npm run dev") // duplicate subsequent command should be ignored
|
||||
RecordSessionCommand("git push origin fix/scoring")
|
||||
|
||||
results, err := SearchHistory("", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(results) < 3 {
|
||||
t.Fatalf("expected at least 3 session commands in search results, got %d", len(results))
|
||||
}
|
||||
|
||||
// newest session command must be results[0]
|
||||
if results[0].Cmd != "git push origin fix/scoring" {
|
||||
t.Errorf("expected results[0] to be 'git push origin fix/scoring', got %q", results[0].Cmd)
|
||||
}
|
||||
if results[1].Cmd != "npm run dev" {
|
||||
t.Errorf("expected results[1] to be 'npm run dev', got %q", results[1].Cmd)
|
||||
}
|
||||
if results[2].Cmd != "git status" {
|
||||
t.Errorf("expected results[2] to be 'git status', got %q", results[2].Cmd)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user