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:
@@ -0,0 +1,87 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TryExtractSkeleton walks the registered subcommand tree for the given command buffer.
|
||||
// It stops when a token doesn't match any registered subcommand name (treating that token as an argument/value)
|
||||
// or when encountering a flag ('-' prefix).
|
||||
// Returns the normalized subcommand skeleton (e.g. "git checkout feature-x" -> "git checkout") and true if a spec exists.
|
||||
func TryExtractSkeleton(buf string) (string, bool) {
|
||||
buf = strings.TrimSpace(buf)
|
||||
if buf == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
aliases := GetAliasesCopy()
|
||||
tokens := Tokenize(buf)
|
||||
// filter out empty tokens (e.g. from trailing space)
|
||||
var cleanTokens []string
|
||||
for _, t := range tokens {
|
||||
if t != "" {
|
||||
cleanTokens = append(cleanTokens, t)
|
||||
}
|
||||
}
|
||||
if len(cleanTokens) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// expand shell alias for the root command if present
|
||||
if target, ok := aliases[cleanTokens[0]]; ok {
|
||||
aliasTokens := Tokenize(target)
|
||||
var cleanAliasTokens []string
|
||||
for _, t := range aliasTokens {
|
||||
if t != "" {
|
||||
cleanAliasTokens = append(cleanAliasTokens, t)
|
||||
}
|
||||
}
|
||||
cleanTokens = append(cleanAliasTokens, cleanTokens[1:]...)
|
||||
}
|
||||
|
||||
if len(cleanTokens) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
rootName := cleanTokens[0]
|
||||
spec, exists := Registry[rootName]
|
||||
if !exists || spec == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
skeletonTokens := []string{rootName}
|
||||
currentSubs := spec.Subcommands
|
||||
|
||||
for i := 1; i < len(cleanTokens); i++ {
|
||||
tok := cleanTokens[i]
|
||||
if strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, sub := range currentSubs {
|
||||
if sub.Name == tok || slices.Contains(sub.Aliases, tok) {
|
||||
skeletonTokens = append(skeletonTokens, sub.Name)
|
||||
currentSubs = sub.Subcommands
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the previous token was a flag (started with '-'), this token might be the flag's argument/value (e.g. -C /tmp).
|
||||
// In that case, we skip this token and continue looking for subcommands in subsequent tokens.
|
||||
if i > 0 && strings.HasPrefix(cleanTokens[i-1], "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// token did not match any registered subcommand and is not a flag argument -> must be a positional argument (branch, file, etc.)
|
||||
break
|
||||
}
|
||||
|
||||
return strings.Join(skeletonTokens, " "), true
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTryExtractSkeleton(t *testing.T) {
|
||||
ResetRegistry()
|
||||
Register(&Spec{
|
||||
Name: "git",
|
||||
Subcommands: []Subcommand{
|
||||
{
|
||||
Name: "checkout",
|
||||
Aliases: []string{"co"},
|
||||
},
|
||||
{
|
||||
Name: "remote",
|
||||
Subcommands: []Subcommand{
|
||||
{Name: "add"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Register(&Spec{
|
||||
Name: "docker",
|
||||
Subcommands: []Subcommand{
|
||||
{
|
||||
Name: "compose",
|
||||
Subcommands: []Subcommand{
|
||||
{Name: "up"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantSkeleton string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "git checkout with argument",
|
||||
input: "git checkout feature-x -b",
|
||||
wantSkeleton: "git checkout",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "git co alias with argument",
|
||||
input: "git co feature-y",
|
||||
wantSkeleton: "git checkout",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "git remote add with arguments",
|
||||
input: "git remote add origin https://github.com/test/test.git",
|
||||
wantSkeleton: "git remote add",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "git global flag before subcommand",
|
||||
input: "git -C /tmp checkout main",
|
||||
wantSkeleton: "git checkout",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "docker compose up with flags",
|
||||
input: "docker compose up -d",
|
||||
wantSkeleton: "docker compose up",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "unregistered spec",
|
||||
input: "cargo build --release",
|
||||
wantSkeleton: "",
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: " ",
|
||||
wantSkeleton: "",
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := TryExtractSkeleton(tc.input)
|
||||
if ok != tc.wantOk {
|
||||
t.Fatalf("TryExtractSkeleton(%q) ok = %v, want %v", tc.input, ok, tc.wantOk)
|
||||
}
|
||||
if got != tc.wantSkeleton {
|
||||
t.Errorf("TryExtractSkeleton(%q) = %q, want %q", tc.input, got, tc.wantSkeleton)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user