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:
+8
-1
@@ -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))
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user