Files
iris-context/root/wrapper.go
T
VERSE 860b475b53 feat: scoring and frecency (#37)
Enhance lookup mechanics and priority propagation (`spec/lookup.go`)
Remove the requirement of typing a leading `-` when searching for
options, allowing flag suggestions to surface naturally during input
Accurately forward the `Priority` field from registry specs into the
`Suggestion` struct so the scoring engine evaluates relevance properly

Integrate scoring into the main suggestion pipeline
(`root/suggestions.go`)
Collect context signals using `scoring.CollectSignals(cwd, query,
rootCmd, store)` with the live working directory retrieved from
`spec.GetCWD()`
Pass the entire deduplicated list across spec, history and AI items
through `scoring.Score(deduped, signals)` to compute scores and sort by
descending order before rendering on screen

Record execution history in Frecency Store (`root/wrapper.go`)
Save the last executed command to `lastSubmittedCommand` whenever Enter
is pressed or a suggestion is selected
Trigger `store.Record(...)` asynchronously within the `IRIS_CMD_STOP`
hook using a dedicated goroutine guarded by panic recovery and timeout
limits, preventing blocks on the main shell thread

Optimize workspace identification for AI
(`internal/ai/context_provider.go`)
Use `workspace.DetectCached(cwd)` to instantly fetch ecosystem context
across Git, Node, Go, Rust, Python and Docker alongside active git
branch state
This caching mechanism avoids redundant subprocess execution when
outside git repositories, significantly speeding up prompt context
generation
2026-07-13 17:56:29 +07:00

1013 lines
27 KiB
Go

package root
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/creack/pty"
"github.com/versenilvis/iris/integration"
"github.com/versenilvis/iris/integration/shell"
"github.com/versenilvis/iris/internal/ai"
"github.com/versenilvis/iris/internal/config"
"github.com/versenilvis/iris/internal/logger"
"github.com/versenilvis/iris/internal/scoring"
"github.com/versenilvis/iris/spec"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
func loadMode() string {
mode := config.Get().Core.Mode
if mode == "last" {
state := config.LoadState()
if state.LastMode == "history" || state.LastMode == "spec" {
return state.LastMode
}
return "spec"
}
if mode == "history" || mode == "spec" {
return mode
}
return "spec"
}
func saveMode(mode string) {
state := config.LoadState()
state.LastMode = mode
_ = config.SaveState(state)
}
var (
oldState *term.State
oldStateMu sync.Mutex
activeMode string
activeModeMu sync.RWMutex
stdoutMu sync.Mutex
)
func writeStdout(data []byte) {
if len(data) == 0 {
return
}
stdoutMu.Lock()
defer stdoutMu.Unlock()
_, _ = os.Stdout.Write(data)
}
// restoreTerminal restores the terminal state if needed
func restoreTerminal() {
oldStateMu.Lock()
defer oldStateMu.Unlock()
if oldState != nil {
_ = term.Restore(int(os.Stdin.Fd()), oldState)
oldState = nil
}
}
// runWrapper sets up the pty environment, launches the shell,
// and manages the main input loop to provide real-time suggestions
// it handles raw terminal mode to intercept keystrokes and
// coordinates between the shell process and the suggestion overlay
func runWrapper() {
var naiveBuffer string
var lastSubmittedCommand string
cursorOffset := 0
var bufferMu sync.Mutex
var userNavigated atomic.Bool
var renderMenuNow func()
r, w, err := os.Pipe() // pipe for ipc communication from shell to iris
if err != nil {
return
}
var shellName string
if active := os.Getenv("IRIS_ACTIVE_SHELL"); active != "" {
shellName = active
_ = os.Unsetenv("IRIS_ACTIVE_SHELL")
} else if shellFlag != "" {
shellName = shellFlag
} else {
shellName = detectShell()
}
shell.Init(shellName)
adapter := shell.Current
ctx := context.Background()
c := exec.CommandContext(ctx, adapter.GetShellPath())
c.ExtraFiles = make([]*os.File, 11)
// pass write end of pipe to shell as fd 13 (since index 10 maps to 13)
c.ExtraFiles[10] = w
c.Env = adapter.GetEnv(13, os.Getpid())
ptmx, err := pty.Start(c)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "[IRIS] failed to start PTY: %v\n", err)
return
}
defer func() { _ = ptmx.Close() }()
_ = pty.InheritSize(os.Stdin, ptmx)
spec.ShellPID = c.Process.Pid
logger.Infof("PTY child shell started: shell=%s, path=%s, pid=%d", shellName, adapter.GetShellPath(), c.Process.Pid)
// put terminal in raw mode to intercept every keystroke
var errMakeRaw error
oldState, errMakeRaw = term.MakeRaw(int(os.Stdin.Fd()))
if errMakeRaw != nil {
logger.Errorf("Failed to set terminal raw mode: %v", errMakeRaw)
panic(errMakeRaw)
}
logger.Debugf("Terminal set to raw mode successfully")
defer restoreTerminal()
sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, syscall.SIGWINCH, syscall.SIGUSR1)
go func() {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
restoreTerminal()
printCrashNotice()
startRescueShell()
os.Exit(2)
}
}()
for s := range sigCh {
switch s {
case syscall.SIGWINCH:
logger.Debugf("Received SIGWINCH terminal resize signal")
_ = pty.InheritSize(os.Stdin, ptmx) // handle terminal window resize
// this is the core feature of reloading
// it helps IRIS reload itself that you dont need to restart the shell manually
// SIGUSR1 is the signal to active reload when you type "just reload"
case syscall.SIGUSR1:
// trigger iris reload by executing itself again
exe, _ := os.Executable()
_ = os.Setenv("IRIS_RELOADED", "true")
innerShell := getActiveInnerShell(c.Process.Pid, shellName)
if innerShell != "" {
// to detect which is last shell (bash, zsh, fish)
_ = os.Setenv("IRIS_ACTIVE_SHELL", innerShell)
}
if c.Process != nil {
cwd, linkErr := os.Readlink(fmt.Sprintf("/proc/%d/cwd", c.Process.Pid))
if linkErr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
out, errCmd := exec.CommandContext(ctx, "lsof", "-p", fmt.Sprintf("%d", c.Process.Pid), "-a", "-d", "cwd", "-F", "n").Output()
cancel()
if errCmd == nil {
for line := range strings.SplitSeq(string(out), "\n") {
if strings.HasPrefix(line, "n") {
cwd = strings.TrimSpace(line[1:])
linkErr = nil
break
}
}
}
}
if linkErr == nil {
_ = os.Chdir(cwd)
}
_ = syscall.Kill(c.Process.Pid, syscall.SIGKILL)
_ = ptmx.Close()
}
restoreTerminal()
execArgs := []string{os.Args[0]}
if logDir, pathErr := config.CachePath(); pathErr == nil {
argsFile := filepath.Join(logDir, "reload-args")
if data, readErr := os.ReadFile(argsFile); readErr == nil {
lines := strings.SplitSeq(string(data), "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
execArgs = append(execArgs, trimmed)
}
}
_ = os.Remove(argsFile)
} else {
execArgs = os.Args
}
} else {
execArgs = os.Args
}
_ = syscall.Exec(exe, execArgs, os.Environ())
}
}
}()
overlay := integration.NewOverlay()
// start background update check (async)
pendingUpdate = startBackgroundUpdateCheck()
updatePrinted := false
shellPGID, err := unix.Getpgid(spec.ShellPID)
if err != nil {
shellPGID = spec.ShellPID
}
var isCommandActive atomic.Bool
isExecuting := func() bool {
if isCommandActive.Load() {
return true
}
pgrp, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP)
if err != nil {
return false
}
return pgrp != shellPGID
}
// bridge pty output to actual stdout
go func() {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
restoreTerminal()
printCrashNotice()
startRescueShell()
os.Exit(2)
}
}()
var lastPromptBuf []byte
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
if err == io.EOF {
restoreTerminal()
os.Exit(0)
}
continue
}
writeStdout(buf[:n])
bufferMu.Lock()
nbEmpty := naiveBuffer == ""
navigated := userNavigated.Load()
bufferMu.Unlock()
if isExecuting() {
lastPromptBuf = nil
} else if nbEmpty && !navigated {
lastPromptBuf = append(lastPromptBuf, buf[:n]...)
if idx := bytes.LastIndexByte(lastPromptBuf, '\n'); idx >= 0 {
lastPromptBuf = append([]byte(nil), lastPromptBuf[idx+1:]...)
}
pLen := integration.ComputeCursorCol(lastPromptBuf)
if pLen >= 0 {
overlay.SetPromptLen(pLen)
}
}
}
}()
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var renderOverlay func()
// listen for suggestion requests from shell scripts via the ipc pipe
go func() {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
restoreTerminal()
printCrashNotice()
startRescueShell()
os.Exit(2)
}
}()
scanner := bufio.NewScanner(r)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\x00'); i >= 0 {
return i + 1, data[0:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
})
for scanner.Scan() {
query := scanner.Text()
if query == "IRIS_CMD_START" {
isCommandActive.Store(true)
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
continue
}
if query == "IRIS_CMD_STOP" {
isCommandActive.Store(false)
SetCurrentAISuggestion(nil)
bufferMu.Lock()
cmdToRecord := lastSubmittedCommand
lastSubmittedCommand = ""
bufferMu.Unlock()
if cmdToRecord != "" {
cwd := spec.GetCWD()
go func(c, d string) {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
}
}()
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)
}
}(cmdToRecord, cwd)
}
// hook: after user executes a command, print the update notice exactly once per session
if !updatePrinted {
select {
case result, ok := <-pendingUpdate:
if ok && result.hasUpdate {
printUpdateNotice(result.latestVersion)
updatePrinted = true
}
default:
}
}
continue
}
isCommandActive.Store(false)
if overlay.GetUserNavigated() {
continue
}
if query == "" {
bufferMu.Lock()
wasEmpty := naiveBuffer == ""
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
if !wasEmpty {
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
}
continue
}
bufferMu.Lock()
if naiveBuffer == query {
bufferMu.Unlock()
continue
}
naiveBuffer = query
cursorOffset = 0
bufferMu.Unlock()
renderOverlay()
}
if err := scanner.Err(); err != nil {
logger.Errorf("IPC scanner error: %v", err)
}
}()
suggestionsEnabled := true
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
writeStdout([]byte(overlay.Clear()))
var renderTimer *time.Timer
var renderMu sync.Mutex
var aiTimer *time.Timer
var aiCancel context.CancelFunc
var aiMu sync.Mutex
renderMenuNow = func() {
if isExecuting() {
return
}
// copy state safely inside timer
bufferMu.Lock()
bufCopy := naiveBuffer
offsetCopy := cursorOffset
bufferMu.Unlock()
activeModeMu.RLock()
modeCopy := activeMode
activeModeMu.RUnlock()
navCopy := userNavigated.Load()
runes := []rune(bufCopy)
if offsetCopy > 0 && offsetCopy <= len(runes) {
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() {
writeStdout([]byte(overlay.ClearAndDisable()))
return
}
logger.Debugf("Render query: '%s', mode: %s", bufCopy, modeCopy)
results := MergeResults(bufCopy, modeCopy)
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.HideMenu(bufCopy))
writeStdout([]byte(b.String()))
return
}
if overlay.IsVisible() {
b.WriteString(overlay.Clear())
}
overlay.SetQueryAndItems(bufCopy, results)
} else {
if overlay.IsVisible() {
b.WriteString(overlay.Clear())
}
}
overlay.SetUserNavigated(navCopy)
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy, offsetCopy == 0))
}
currentCmd := overlay.GetCurrentCmd()
logger.Debugf("RenderOverlay nav: %v, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.GetTypedQuery(), currentCmd)
b.WriteString(overlay.Render())
writeStdout([]byte(b.String()))
}
renderOverlay = func() {
renderMu.Lock()
defer renderMu.Unlock()
if !suggestionsEnabled || isExecuting() {
if renderTimer != nil {
renderTimer.Stop()
renderTimer = nil
}
return
}
if userNavigated.Load() {
return
}
if renderTimer != nil {
renderTimer.Stop()
}
renderTimer = time.AfterFunc(25*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
renderMu.Unlock()
renderMenuNow()
})
}
renderOverlay()
// reads from stdin and decides what to forward or intercept
// for most cases, I just handle the already have terminal shortcuts
// for some shortcuts like tab, enter, shift tab, ctrl r,
// they have a little bit different behavior to match our tool
for {
inputSlice := make([]byte, 128)
n, err := os.Stdin.Read(inputSlice)
if err != nil {
break
}
if n > 0 {
if isExecuting() {
_, _ = ptmx.Write(inputSlice[:n])
continue
}
logger.Debugf("Stdin raw input: bytes=%q, hex=%x", inputSlice[:n], inputSlice[:n])
shouldOverlayDraw := false
for i := 0; i < n; i++ {
b := inputSlice[i]
intercepted := false
if b == '\033' {
// check for bracketed paste start/end
if i+5 < n && inputSlice[i+1] == '[' && inputSlice[i+2] == '2' && inputSlice[i+3] == '0' {
if (inputSlice[i+4] == '0' || inputSlice[i+4] == '1') && inputSlice[i+5] == '~' {
intercepted = true
logger.Debugf("Intercepted bracketed paste event")
_, _ = ptmx.Write(inputSlice[i : i+6])
i += 5
continue
}
}
// handle escape sequences like arrow keys and functional shortcuts
if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') {
// shift tab: hide/unhide menu dropdown
if inputSlice[i+1] == '[' && inputSlice[i+2] == 'Z' {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
logger.Debugf("Intercepted Shift+Tab, suggestionsEnabled=%v", suggestionsEnabled)
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += 2
continue
}
if overlay.IsVisible() && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') {
intercepted = true
userNavigated.Store(true)
arrowDir := "down"
if inputSlice[i+2] == 'A' {
arrowDir = "up"
}
moved, _ := overlay.MoveCursor(arrowDir)
if !moved {
i += 2
continue
}
bufferMu.Lock()
bufCopy := naiveBuffer
offsetCopy := cursorOffset
bufferMu.Unlock()
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
} else if !overlay.IsVisible() && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt
intercepted = true
activeModeMu.Lock()
activeMode = "history"
saveMode(activeMode)
activeModeMu.Unlock()
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
results := MergeResults("", currentMode)
if len(results) > 0 {
limit := min(len(results), 100)
var historyList []spec.Suggestion
if inputSlice[i+2] == 'A' {
for j := limit - 1; j >= 0; j-- {
historyList = append(historyList, results[j])
}
} else {
for j := range limit {
historyList = append(historyList, results[j])
}
}
selected := overlay.SetHistoryList(historyList, inputSlice[i+2] == 'A')
if selected != "" {
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
userNavigated.Store(true)
writeStdout([]byte(overlay.Render()))
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
}
i += 2
continue
} else if !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
bufferMu.Lock()
atEnd := (cursorOffset == 0)
ghostText := overlay.GetGhostText(naiveBuffer, atEnd)
bufferMu.Unlock()
if len(ghostText) > 0 {
intercepted = true
logger.Debugf("Intercepted Right Arrow (accepted ghost text: %q)", ghostText)
bufferMu.Lock()
naiveBuffer += ghostText
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true
i += 2
continue
}
}
}
// left/right arrow cursor tracking
isLeftRightArrow := false
if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') {
if inputSlice[i+2] == 'D' {
bufferMu.Lock()
isEmptyQuery := naiveBuffer == "" && (!overlay.IsVisible() || overlay.GetTypedQuery() == "")
bufferMu.Unlock()
if isEmptyQuery {
intercepted = true
i += 2
continue
}
bufferMu.Lock()
if naiveBuffer != "" || overlay.IsVisible() {
cursorOffset++
if cursorOffset > len(naiveBuffer) {
cursorOffset = len(naiveBuffer)
}
shouldOverlayDraw = true
userNavigated.Store(false)
}
bufferMu.Unlock()
isLeftRightArrow = true
} else if inputSlice[i+2] == 'C' {
bufferMu.Lock()
isEmptyQuery := naiveBuffer == "" && (!overlay.IsVisible() || overlay.GetTypedQuery() == "")
bufferMu.Unlock()
if isEmptyQuery {
intercepted = true
i += 2
continue
}
bufferMu.Lock()
if naiveBuffer != "" || overlay.IsVisible() {
cursorOffset--
if cursorOffset < 0 {
cursorOffset = 0
}
shouldOverlayDraw = true
userNavigated.Store(false)
}
bufferMu.Unlock()
isLeftRightArrow = true
}
}
if !intercepted {
writeStdout([]byte(overlay.ClearAndDisable()))
disableGhostText.Store(true)
if !isLeftRightArrow {
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
}
_, _ = ptmx.Write([]byte{b})
// skip remaining bytes of the escape sequence to avoid misinterpretation
for j := i + 1; j < n; j++ {
char := inputSlice[j]
_, _ = ptmx.Write([]byte{char})
i = j
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '~' {
break
}
}
}
continue
}
if b == 0x12 { // ctrl+r: toggle between command specs and command history
intercepted = true
activeModeMu.Lock()
if activeMode == "spec" {
activeMode = "history"
} else {
activeMode = "spec"
}
saveMode(activeMode)
activeModeMu.Unlock()
logger.Debugf("Intercepted Ctrl+R, toggled mode to %q", activeMode)
if userNavigated.Load() {
bufferMu.Lock()
naiveBuffer = overlay.GetTypedQuery()
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, overlay.GetTypedQuery()...))
}
userNavigated.Store(false)
overlay.Show()
shouldOverlayDraw = true
// enter: enter behavior is a bit different from tab suggestions in code editor
// I want it to execute the command anyway and ignore the suggestions
// it means only tab to select suggestions, and enter to execute
// enter is not used to select suggestions
} else if b == 0x0d || b == 0x0a {
intercepted = true
logger.Debugf("Intercepted Enter key, navigated=%v", overlay.GetUserNavigated())
var cmdToSubmit string
if overlay.IsVisible() && overlay.GetUserNavigated() {
selected := overlay.GetCurrentCmd()
if selected != "" {
cmdToSubmit = selected
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " "
}
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
}
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
renderMu.Lock()
if renderTimer != nil {
renderTimer.Stop()
renderTimer = nil
}
renderMu.Unlock()
isCommandActive.Store(true)
_, _ = ptmx.Write([]byte{b})
bufferMu.Lock()
if cmdToSubmit == "" {
cmdToSubmit = naiveBuffer
}
lastSubmittedCommand = strings.TrimSpace(cmdToSubmit)
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated.Store(false)
continue
} 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()
renderTimer = nil
}
renderMu.Unlock()
isCommandActive.Store(false)
_, _ = ptmx.Write([]byte{b})
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated.Store(false)
continue
} else if b == 0x09 { // tab: select suggestions
intercepted = true
logger.Debugf("Intercepted Tab key, visible=%v", overlay.IsVisible())
if !overlay.IsVisible() {
shouldOverlayDraw = true
} else {
selected := overlay.GetCurrentCmd()
writeStdout([]byte(overlay.ClearAndDisable()))
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " "
}
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
overlay.ResetCursor() // this prevents when you tab, it switches between suggestions non-stop
shouldOverlayDraw = true // <- rerender after tab to choose, if you set to false,
// when you press tab continually, it will print all folder from menu suggestions
// and make the cursor jump to next line
userNavigated.Store(false)
}
continue
}
if !intercepted {
_, _ = ptmx.Write([]byte{b})
// we handle line editing keys manually to keep naiveBuffer in sync
// since terminal is in raw mode, we must update our state for every change
switch b {
case 0x01: // ctrl+a: move to beginning of line
bufferMu.Lock()
cursorOffset = len(naiveBuffer)
if naiveBuffer != "" || overlay.IsVisible() {
shouldOverlayDraw = true
}
bufferMu.Unlock()
userNavigated.Store(false)
case 0x05: // ctrl+e: move to end of line
bufferMu.Lock()
cursorOffset = 0
if naiveBuffer != "" || overlay.IsVisible() {
shouldOverlayDraw = true
}
bufferMu.Unlock()
userNavigated.Store(false)
case 127, 0x08: // backspace: remove character
bufferMu.Lock()
wasEmpty := len(naiveBuffer) == 0
if !wasEmpty {
runes := []rune(naiveBuffer)
if cursorOffset <= 0 {
if len(runes) > 0 {
naiveBuffer = string(runes[:len(runes)-1])
}
cursorOffset = 0
} else {
if cursorOffset > len(runes) {
cursorOffset = len(runes)
}
pos := len(runes) - cursorOffset
if pos > 0 && pos <= len(runes) {
naiveBuffer = string(append(runes[:pos-1], runes[pos:]...))
}
}
}
isEmptyNow := len(naiveBuffer) == 0
bufferMu.Unlock()
if wasEmpty || isEmptyNow {
writeStdout([]byte(overlay.ClearAndDisable()))
userNavigated.Store(false)
continue
}
shouldOverlayDraw = true
userNavigated.Store(false)
case 0x17: // ctrl+w: delete the last word in the buffer
bufferMu.Lock()
wasEmpty := len(naiveBuffer) == 0
trimBuf := strings.TrimRight(naiveBuffer, " ")
lastSpace := strings.LastIndex(trimBuf, " ")
if lastSpace >= 0 {
naiveBuffer = trimBuf[:lastSpace+1]
} else {
naiveBuffer = ""
}
cursorOffset = 0
isEmptyNow := len(naiveBuffer) == 0
bufferMu.Unlock()
if wasEmpty || isEmptyNow {
writeStdout([]byte(overlay.ClearAndDisable()))
userNavigated.Store(false)
continue
}
shouldOverlayDraw = true
userNavigated.Store(false)
case 0x0c: // ctrl+l: clear screen but keep buffer and redraw menu
shouldOverlayDraw = true
userNavigated.Store(false)
case '\r', '\n', 0x03, 0x15: // enter, ctrl+c, ctrl+u: clear buffer on line reset
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
disableGhostText.Store(false)
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
userNavigated.Store(false)
default:
// track normal printable characters in the buffer for matching
if b >= 32 && b <= 126 {
// if user presses space, check if the current word is an alias
bufferMu.Lock()
isSpaceAlias := b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ")
var target string
var ok bool
if isSpaceAlias {
target, ok = spec.GetAlias(naiveBuffer)
}
bufferMu.Unlock()
if isSpaceAlias && ok {
// clear the current alias and replace it with the full command
_, _ = ptmx.Write(append([]byte{0x15}, target+" "...))
bufferMu.Lock()
naiveBuffer = target + " "
cursorOffset = 0
bufferMu.Unlock()
shouldOverlayDraw = true
continue
}
bufferMu.Lock()
if cursorOffset == 0 {
naiveBuffer += string(b)
} else {
if cursorOffset > len(naiveBuffer) {
cursorOffset = len(naiveBuffer)
}
pos := len(naiveBuffer) - cursorOffset
if pos >= 0 && pos <= len(naiveBuffer) {
naiveBuffer = naiveBuffer[:pos] + string(b) + naiveBuffer[pos:]
} else {
naiveBuffer += string(b)
cursorOffset = 0
}
}
bufferMu.Unlock()
shouldOverlayDraw = true
userNavigated.Store(false)
overlay.SetUserNavigated(false)
}
}
}
}
if shouldOverlayDraw {
renderOverlay()
}
}
}
}