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:
VERSE
2026-07-26 19:10:56 +07:00
committed by GitHub
parent 85ac76e85a
commit 6ffb9f83f2
20 changed files with 1004 additions and 72 deletions
+8 -1
View File
@@ -76,6 +76,13 @@ func MergeResults(query string, mode string) []spec.Suggestion {
}
}
if mode == "history" && normalizedQuery == "" {
if len(deduped) > maxSugg {
deduped = deduped[:maxSugg]
}
return deduped
}
if aiSugg := GetCurrentAISuggestion(); aiSugg != nil {
normalizedCmd := strings.TrimSpace(aiSugg.Cmd)
if normalizedCmd != "" && normalizedCmd != normalizedQuery && strings.HasPrefix(strings.ToLower(normalizedCmd), strings.ToLower(normalizedQuery)) {
@@ -105,7 +112,7 @@ func MergeResults(query string, mode string) []spec.Suggestion {
ctxTimeout, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
store, _ := scoring.GetFrecencyStore()
signals := scoring.CollectSignals(ctxTimeout, cwd, query, rootCmd, store)
signals := scoring.CollectSignals(ctxTimeout, cwd, query, rootCmd, store, getPrevSkeleton())
scored := scoring.Score(deduped, signals)
finalResults := make([]spec.Suggestion, 0, len(scored))
+29 -1
View File
@@ -20,7 +20,6 @@ func TestMergeResults(t *testing.T) {
}
})
t.Run("Limit 100", func(t *testing.T) {
res := MergeResults("a", "history")
if len(res) > 100 {
@@ -50,3 +49,32 @@ func TestMergeResults(t *testing.T) {
}
})
}
func TestPrevRecordedCommandState(t *testing.T) {
origRegistry := spec.Registry
t.Cleanup(func() {
spec.Registry = origRegistry
})
spec.ResetRegistry()
spec.Register(&spec.Spec{
Name: "git",
Subcommands: []spec.Subcommand{
{Name: "checkout"},
},
})
setPrevRecordedInfo("git checkout feature-abc", "/repo/dir")
skel, cwd := getPrevRecordedInfo()
if skel != "git checkout" || cwd != "/repo/dir" {
t.Errorf("expected skel='git checkout' and cwd='/repo/dir', got skel=%q, cwd=%q", skel, cwd)
}
if gotSkel := getPrevSkeleton(); gotSkel != "git checkout" {
t.Errorf("expected getPrevSkeleton()='git checkout', got %q", gotSkel)
}
setPrevRecordedInfo("", "")
if gotSkel := getPrevSkeleton(); gotSkel != "" {
t.Errorf("expected empty getPrevSkeleton() after reset, got %q", gotSkel)
}
}
+63 -5
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -28,6 +29,37 @@ import (
"golang.org/x/term"
)
var (
prevRecordedCommand string
prevCmdCwd string
prevCmdMu sync.Mutex
)
func getPrevSkeleton() string {
prevCmdMu.Lock()
defer prevCmdMu.Unlock()
if prevRecordedCommand == "" {
return ""
}
return scoring.ExtractSkeleton(prevRecordedCommand)
}
func getPrevRecordedInfo() (string, string) {
prevCmdMu.Lock()
defer prevCmdMu.Unlock()
if prevRecordedCommand == "" {
return "", ""
}
return scoring.ExtractSkeleton(prevRecordedCommand), prevCmdCwd
}
func setPrevRecordedInfo(cmd, cwd string) {
prevCmdMu.Lock()
defer prevCmdMu.Unlock()
prevRecordedCommand = cmd
prevCmdCwd = cwd
}
func loadMode() string {
mode := config.Get().Core.Mode
if mode == "last" {
@@ -322,7 +354,13 @@ func runWrapper() {
continue
}
if query == "IRIS_CMD_STOP" {
if query == "IRIS_CMD_STOP" || strings.HasPrefix(query, "IRIS_CMD_STOP:") {
exitCode := 0
if after, ok := strings.CutPrefix(query, "IRIS_CMD_STOP:"); ok {
if code, err := strconv.Atoi(after); err == nil {
exitCode = code
}
}
isCommandActive.Store(false)
SetCurrentAISuggestion(nil)
bufferMu.Lock()
@@ -330,8 +368,11 @@ func runWrapper() {
lastSubmittedCommand = ""
bufferMu.Unlock()
if cmdToRecord != "" {
integration.RecordSessionCommand(cmdToRecord)
cwd := spec.GetCWD()
go func(c, d string) {
prevSkeleton, prevCwd := getPrevRecordedInfo()
currSkeleton := scoring.ExtractSkeleton(cmdToRecord)
go func(c, d string, code int, pSkel, pCwd, cSkel string) {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
@@ -340,9 +381,13 @@ func runWrapper() {
ctxRecord, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
if store, err := scoring.GetFrecencyStore(); err == nil && store != nil {
_ = store.Record(ctxRecord, c, d)
_ = store.Record(ctxRecord, c, d, code)
if pSkel != "" && cSkel != "" {
_ = store.RecordTransition(ctxRecord, pSkel, cSkel, d, code)
}
}
}(cmdToRecord, cwd)
}(cmdToRecord, cwd, exitCode, prevSkeleton, prevCwd, currSkeleton)
setPrevRecordedInfo(cmdToRecord, cwd)
}
// hook: after user executes a command, print the update notice exactly once per session
if !updatePrinted {
@@ -605,17 +650,30 @@ func runWrapper() {
if inputSlice[i+2] == 'A' {
arrowDir = "up"
}
moved, _ := overlay.MoveCursor(arrowDir)
moved, selectedCmd := overlay.MoveCursor(arrowDir)
if !moved {
i += 2
continue
}
bufferMu.Lock()
activeModeMu.RLock()
isHistMode := activeMode == "history"
activeModeMu.RUnlock()
var toWrite []byte
if isHistMode && selectedCmd != "" {
naiveBuffer = selectedCmd
cursorOffset = 0
toWrite = append([]byte{0x15}, selectedCmd...)
}
bufCopy := naiveBuffer
offsetCopy := cursorOffset
bufferMu.Unlock()
if len(toWrite) > 0 {
_, _ = ptmx.Write(toWrite)
}
var b strings.Builder
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0))