- redundant ghost text - prompt deletion bug on overlay - overlay bug on the non-exists commands <img width="1152" height="221" alt="image" src="https://github.com/user-attachments/assets/29ef0c1f-4cd8-4e36-960f-352010fc3bd1" /> <img width="1275" height="470" alt="image" src="https://github.com/user-attachments/assets/aa765110-f9fa-49c8-94f2-767a827f5e5f" /> <img width="2517" height="152" alt="image" src="https://github.com/user-attachments/assets/47a6668f-fb01-46be-9ad2-2c50c283f175" />
937 lines
25 KiB
Go
937 lines
25 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/commands/core"
|
|
"github.com/versenilvis/iris/config"
|
|
"github.com/versenilvis/iris/integration"
|
|
"github.com/versenilvis/iris/integration/shell"
|
|
"github.com/versenilvis/iris/logger"
|
|
"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
|
|
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)
|
|
core.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.Split(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.Split(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(core.ShellPID)
|
|
if err != nil {
|
|
shellPGID = core.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()))
|
|
continue
|
|
}
|
|
|
|
if query == "IRIS_CMD_STOP" {
|
|
isCommandActive.Store(false)
|
|
// 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()))
|
|
}
|
|
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
|
|
|
|
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])
|
|
}
|
|
|
|
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.ClearAndDisable())
|
|
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))
|
|
}
|
|
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)
|
|
|
|
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)
|
|
if !moved {
|
|
i += 2
|
|
continue
|
|
}
|
|
|
|
bufferMu.Lock()
|
|
naiveBuffer = selected
|
|
cursorOffset = 0
|
|
bufferMu.Unlock()
|
|
|
|
writeStdout([]byte(overlay.Render()))
|
|
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
|
|
|
|
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 := 100
|
|
if len(results) < limit {
|
|
limit = len(results)
|
|
}
|
|
var historyList []core.Suggestion
|
|
|
|
if inputSlice[i+2] == 'A' {
|
|
for j := limit - 1; j >= 0; j-- {
|
|
historyList = append(historyList, results[j])
|
|
}
|
|
} else {
|
|
for j := 0; j < limit; j++ {
|
|
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 overlay.IsVisible() && !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):]
|
|
}
|
|
bufferMu.Unlock()
|
|
|
|
if hasMatch && 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 := (overlay.IsVisible() && overlay.GetTypedQuery() == "") || (!overlay.IsVisible() && naiveBuffer == "")
|
|
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 := (overlay.IsVisible() && overlay.GetTypedQuery() == "") || (!overlay.IsVisible() && naiveBuffer == "")
|
|
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())
|
|
if overlay.IsVisible() && overlay.GetUserNavigated() {
|
|
selected := overlay.GetCurrentCmd()
|
|
if selected != "" {
|
|
activeModeMu.RLock()
|
|
currentMode := activeMode
|
|
activeModeMu.RUnlock()
|
|
if currentMode == "spec" {
|
|
selected = strings.TrimSpace(selected) + " "
|
|
}
|
|
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
|
|
}
|
|
}
|
|
writeStdout([]byte(overlay.ClearAndDisable()))
|
|
renderMu.Lock()
|
|
if renderTimer != nil {
|
|
renderTimer.Stop()
|
|
renderTimer = nil
|
|
}
|
|
renderMu.Unlock()
|
|
|
|
isCommandActive.Store(true)
|
|
_, _ = ptmx.Write([]byte{b})
|
|
bufferMu.Lock()
|
|
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()))
|
|
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()))
|
|
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 = core.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()
|
|
}
|
|
}
|
|
}
|
|
}
|