feat: AI suggestion (#34)

## Feat
* feat(ai): add AI suggestion engine, context gathering, and ghost text
overlay (`v0.3.0`)

## Security & Perf
* fix(ai): restrict background `--help` execution to a hardcoded command
allowlist to prevent RCE
* fix(root): defer context cancellation in goroutine to prevent resource
leaks
* perf(ai): implement LRU eviction and maximum size limit for
`ProviderCache`
* perf(ai): truncate command context output
(`CommandContextProvider.Gather`) to 1000 characters to save tokens

## Bug Fixes
* fix(ai): add mutex synchronization and thread-safe snapshotting to
`AIEngine.RegisterProvider` and `GatherDynamicContext`
* fix(ai): return a copy of fresh `Suggestion` in `AIEngine.Suggest` to
prevent cache mutation
* fix(ai): use length-prefixed encoding in `EnvSnapshot.Hash` to prevent
delimiter collisions
* fix(ai): skip variable assignment lines containing `=` when extracting
Makefile targets
* fix(runner): check `scanner.Err()` and return `nil` on scan errors in
justfile generator
* test(ci): rename `git commit` to `git checkout` in overlay test to
resolve CI `typos` false positive
This commit is contained in:
VERSE
2026-07-11 15:17:14 +07:00
committed by GitHub
parent 4728ca0c7f
commit 94c8af7b1d
29 changed files with 2263 additions and 45 deletions
+76 -24
View File
@@ -17,11 +17,12 @@ import (
"time"
"github.com/creack/pty"
"github.com/versenilvis/iris/spec"
"github.com/versenilvis/iris/ai"
"github.com/versenilvis/iris/config"
"github.com/versenilvis/iris/integration"
"github.com/versenilvis/iris/integration/shell"
"github.com/versenilvis/iris/logger"
"github.com/versenilvis/iris/spec"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
@@ -315,11 +316,13 @@ func runWrapper() {
cursorOffset = 0
bufferMu.Unlock()
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
continue
}
if query == "IRIS_CMD_STOP" {
isCommandActive.Store(false)
SetCurrentAISuggestion(nil)
// hook: after user executes a command, print the update notice exactly once per session
if !updatePrinted {
select {
@@ -348,6 +351,7 @@ func runWrapper() {
bufferMu.Unlock()
if !wasEmpty {
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
}
continue
}
@@ -377,6 +381,9 @@ func runWrapper() {
var renderTimer *time.Timer
var renderMu sync.Mutex
var aiTimer *time.Timer
var aiCancel context.CancelFunc
var aiMu sync.Mutex
renderMenuNow = func() {
if isExecuting() {
@@ -400,6 +407,56 @@ func runWrapper() {
bufCopy = string(runes[:len(runes)-offsetCopy])
}
aiMu.Lock()
if aiTimer != nil {
aiTimer.Stop()
}
if aiCancel != nil {
aiCancel()
aiCancel = nil
}
if config.Get().AI.Enabled && bufCopy != "" && !navCopy && offsetCopy == 0 {
queryTarget := bufCopy
debounceMS := config.Get().AI.DebounceMS
if debounceMS <= 0 {
debounceMS = 500
}
aiTimer = time.AfterFunc(time.Duration(debounceMS)*time.Millisecond, func() {
// Require at least 3 characters to trigger AI completion to save API quota and avoid 6000 TPM limit (Groq api docs)
if len(strings.TrimSpace(queryTarget)) < 3 {
return
}
aiMu.Lock()
ctx, cancel := context.WithCancel(context.Background())
aiCancel = cancel
aiMu.Unlock()
defer cancel()
cwd := spec.GetCWD()
var recentCmds []string
var lastCmd string
if hist, err := integration.SearchHistory("", nil); err == nil {
// Limit to 3 recent commands to keep prompt concise and reduce token consumption
for i := 0; i < len(hist) && i < 3; i++ {
recentCmds = append(recentCmds, hist[i].Cmd)
}
if len(recentCmds) > 0 {
lastCmd = recentCmds[0]
}
}
env := ai.NewEnvSnapshot(cwd, lastCmd, 0, recentCmds)
sugg, err := GetAIEngine().Suggest(ctx, queryTarget, env, "")
if err != nil || sugg == nil || ctx.Err() != nil {
return
}
SetCurrentAISuggestion(sugg)
if overlay.InjectAISuggestion(*sugg) {
renderOverlay()
}
})
}
aiMu.Unlock()
var b strings.Builder
if !navCopy {
if bufCopy == "" && !overlay.IsVisible() {
@@ -411,7 +468,7 @@ func runWrapper() {
logger.Debugf("Render results found: %d", len(results))
if len(results) == 0 || (len(results) == 1 && strings.TrimSpace(results[0].Cmd) == strings.TrimSpace(bufCopy) && !strings.HasSuffix(bufCopy, " ")) {
b.WriteString(overlay.ClearAndDisable())
b.WriteString(overlay.HideMenu(bufCopy))
writeStdout([]byte(b.String()))
return
}
@@ -428,7 +485,7 @@ func runWrapper() {
overlay.SetUserNavigated(navCopy)
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy))
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy, offsetCopy == 0))
}
currentCmd := overlay.GetCurrentCmd()
logger.Debugf("RenderOverlay nav: %v, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.GetTypedQuery(), currentCmd)
@@ -520,31 +577,27 @@ func runWrapper() {
intercepted = true
userNavigated.Store(true)
if l := overlay.ClearGhostLen(); l > 0 {
var gs strings.Builder
gs.WriteString("\0337")
gs.WriteString(strings.Repeat(" ", l+10))
gs.WriteString("\0338")
writeStdout([]byte(gs.String()))
}
arrowDir := "down"
if inputSlice[i+2] == 'A' {
arrowDir = "up"
}
moved, selected := overlay.MoveCursor(arrowDir)
moved, _ := overlay.MoveCursor(arrowDir)
if !moved {
i += 2
continue
}
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufCopy := naiveBuffer
offsetCopy := cursorOffset
bufferMu.Unlock()
writeStdout([]byte(overlay.Render()))
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
var b strings.Builder
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0))
}
b.WriteString(overlay.Render())
writeStdout([]byte(b.String()))
i += 2
continue
@@ -587,17 +640,13 @@ func runWrapper() {
}
i += 2
continue
} else if overlay.IsVisible() && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
} else if !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
bufferMu.Lock()
topCmd := overlay.GetTopCmd()
hasMatch := strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer))
var ghostText string
if hasMatch {
ghostText = topCmd[len(naiveBuffer):]
}
atEnd := (cursorOffset == 0)
ghostText := overlay.GetGhostText(naiveBuffer, atEnd)
bufferMu.Unlock()
if hasMatch && len(ghostText) > 0 {
if len(ghostText) > 0 {
intercepted = true
logger.Debugf("Intercepted Right Arrow (accepted ghost text: %q)", ghostText)
bufferMu.Lock()
@@ -723,6 +772,7 @@ func runWrapper() {
}
}
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
renderMu.Lock()
if renderTimer != nil {
renderTimer.Stop()
@@ -743,6 +793,7 @@ func runWrapper() {
} else if b == 0x03 || b == 0x15 { // ctrl+c, ctrl+u
intercepted = true
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
renderMu.Lock()
if renderTimer != nil {
renderTimer.Stop()
@@ -878,6 +929,7 @@ func runWrapper() {
activeModeMu.Unlock()
disableGhostText.Store(false)
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
userNavigated.Store(false)
default:
// track normal printable characters in the buffer for matching