Files
iris-context/root/wrapper.go
T
VERSE fc1634b007 fix: start iris in multiplexer panes instead of inheriting a dead one (#120)
closes #62
when you open a new Zellij pane, the shell inherits Iris’s environment
variables, so it thinks Iris is already running. But the pane doesn't
inherit the underlying connection (FD 13), so suggestions silently fail
into a dead pipe

the old code only checked for `tmux` by process name, ignoring Zellij
completely

- Fixed:

instead of checking for specific app names, Iris now tracks the active
terminal device (`IRIS_TTY`)

if a new shell detects that it's on a different `TTY` than Iris, it
clears the stale environment variables so Iris can start fresh. The
`IRIS_PID != PPID` check prevents Iris from trying to re-exec itself
when spawning sub-shells.

```sh
if [ -n "$IRIS_PID" ] && [ "$IRIS_PID" != "$PPID" ] && [ "${TTY:-$(tty 2>/dev/null)}" != "$IRIS_TTY" ]; then
    unset IRIS_PID IRIS_IS_CHILD IRIS_FD IRIS_TTY
fi

```
2026-08-06 15:42:56 +07:00

1239 lines
33 KiB
Go

package root
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"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"
)
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" {
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)
}
func shellArgs(login bool) []string {
if login {
return []string{"--login"}
}
return nil
}
var (
oldState *term.State
oldStateFd int
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(oldStateFd, 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()
var intercepted bool
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(), shellArgs(config.Get().Core.ShellLogin)...)
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
ptmx, tts, err := pty.Open()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "[IRIS] failed to open PTY: %v\n", err)
return
}
defer func() { _ = ptmx.Close() }()
c.Stdin, c.Stdout, c.Stderr = tts, tts, tts
c.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
// the shell compares its own tty against this to tell whether the inherited
// IRIS_* vars belong to it or leaked in from an outer terminal
c.Env = append(adapter.GetEnv(13, os.Getpid()), "IRIS_TTY="+tts.Name())
if err = c.Start(); err != nil {
_ = tts.Close()
_, _ = fmt.Fprintf(os.Stderr, "[IRIS] failed to start shell: %v\n", err)
return
}
_ = tts.Close()
stdinFile := os.Stdin
if !term.IsTerminal(int(stdinFile.Fd())) {
if tty, ttyErr := os.OpenFile("/dev/tty", os.O_RDWR, 0); ttyErr == nil {
stdinFile = tty
}
}
_ = pty.InheritSize(stdinFile, 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
if term.IsTerminal(int(stdinFile.Fd())) {
oldState, errMakeRaw = term.MakeRaw(int(stdinFile.Fd()))
if errMakeRaw != nil {
logger.Errorf("Failed to set terminal raw mode: %v", errMakeRaw)
panic(errMakeRaw)
}
oldStateFd = int(stdinFile.Fd())
logger.Debugf("Terminal set to raw mode successfully")
defer func() {
oldStateMu.Lock()
defer oldStateMu.Unlock()
if oldState != nil {
_ = term.Restore(int(stdinFile.Fd()), oldState)
oldState = nil
}
}()
} else {
logger.Warnf("stdinFile is not a terminal, skipping raw mode")
}
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(stdinFile, 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
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var renderOverlayFn atomic.Value // holds func()
renderOverlayFn.Store(func() {})
config.AutoDetectConfigChange(func(cfg *config.Config) {
disableGhostText.Store(!cfg.UI.GhostText)
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
})
isExecuting := func() bool {
if isCommandActive.Load() {
// for bash: no preexec/precmd hooks, so fall back to TIOCGPGRP to detect when shell returns
if shellName == "bash" {
pgrp, pgrpErr := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP)
if pgrpErr == nil && pgrp == shellPGID {
isCommandActive.Store(false)
return false
}
}
return true
}
pgrp, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP)
if err != nil {
return false
}
return pgrp != shellPGID
}
suggestionsEnabled := true
// Shared handler for configured navigation keys (e.g. ctrl+j / ctrl+k).
// Moves the overlay cursor when visible, otherwise opens history/spec
// list and selects the next item in the requested direction.
handleNavKey := func(dir string) {
if overlay.IsVisible() {
intercepted = true
userNavigated.Store(true)
arrowDir := "down"
if dir == "up" {
arrowDir = "up"
}
moved, selectedCmd := overlay.MoveCursor(arrowDir)
if !moved {
return
}
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))
}
b.WriteString(overlay.Render())
writeStdout([]byte(b.String()))
} else if suggestionsEnabled {
// hidden-overlay history navigation only when suggestions are enabled;
// otherwise let the navigation keys pass through to the shell
intercepted = true
userNavigated.Store(true)
activeModeMu.Lock()
if activeMode == "" {
activeMode = loadMode()
}
activeModeMu.Unlock()
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
bufferMu.Lock()
bufQuery := naiveBuffer
bufferMu.Unlock()
results := MergeResults(bufQuery, currentMode)
if len(results) > 0 {
limit := min(len(results), 100)
var historyList []spec.Suggestion
if dir == "up" {
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, dir == "up")
if selected != "" {
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
userNavigated.Store(true)
writeStdout([]byte(overlay.Render()))
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
}
}
}
// 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 {
restoreTerminal()
if err == io.EOF || errors.Is(err, syscall.EIO) || strings.Contains(err.Error(), "input/output error") {
os.Exit(0)
}
logger.Errorf("Unexpected PTY read error: %v", err)
os.Exit(1)
}
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)
}
}
}
}()
// 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 cwd, ok := strings.CutPrefix(query, "IRIS_CWD:"); ok {
spec.SetCWD(cwd)
continue
}
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" || 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()
cmdToRecord := lastSubmittedCommand
lastSubmittedCommand = ""
bufferMu.Unlock()
if cmdToRecord != "" {
cwd := spec.GetCWD()
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)
}
}()
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, code)
if pSkel != "" && cSkel != "" {
_ = store.RecordTransition(ctxRecord, pSkel, cSkel, d, code)
}
}
}(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 {
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()
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
if err := scanner.Err(); err != nil {
logger.Errorf("IPC scanner error: %v", err)
}
}()
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) {
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
})
}
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()))
}
renderOverlayFn.Store(func() {
renderMu.Lock()
defer renderMu.Unlock()
if !suggestionsEnabled || isExecuting() {
if renderTimer != nil {
renderTimer.Stop()
renderTimer = nil
}
return
}
if userNavigated.Load() {
return
}
if renderTimer != nil {
return
}
renderTimer = time.AfterFunc(20*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
renderMu.Unlock()
renderMenuNow()
})
})
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
// 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
inBracketedPaste := false
for {
inputSlice := make([]byte, 128)
n, err := stdinFile.Read(inputSlice)
if err != nil {
break
}
if n > 0 {
if isExecuting() {
inBracketedPaste = false
_, _ = ptmx.Write(inputSlice[:n])
continue
}
shouldOverlayDraw := false
for i := 0; i < n; i++ {
b := inputSlice[i]
intercepted = false
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += consumed - 1
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMode); matched { // ctrl+r: toggle between command specs and command history
i += consumed - 1
intercepted = true
activeModeMu.Lock()
if activeMode == "spec" {
activeMode = "history"
} else {
activeMode = "spec"
}
saveMode(activeMode)
activeModeMu.Unlock()
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
continue
}
var isNavUp, isNavDown bool
var navConsumed int
if isNavUp, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp); !isNavUp {
isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown)
}
if isNavUp || isNavDown {
arrowDir := "down"
if isNavUp {
arrowDir = "up"
}
handleNavKey(arrowDir)
i += navConsumed - 1
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" {
if overlay.IsVisible() {
intercepted = true
selected := overlay.GetCurrentCmd()
if selected != "" {
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
s := strings.TrimSpace(selected)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selected = s
} else {
selected = s + " "
}
}
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
overlay.ClearGhostTextState()
userNavigated.Store(false)
writeStdout([]byte(overlay.Render()))
}
}
// always consume the full binding atomically, even when the overlay is hidden
i += consumed - 1
continue
}
if b == 0x0d || b == 0x0a { // enter
intercepted = true
if b == 0x0d && i+1 < n && inputSlice[i+1] == 0x0a {
i++ // consume trailing \n in \r\n to prevent matching ctrl+j
}
var selectedCmd string
var shouldAutoExecute bool
if overlay.IsVisible() && (config.Get().Core.AutoExecute || userNavigated.Load()) {
selectedCmd = overlay.GetCurrentCmd()
if selectedCmd != "" {
shouldAutoExecute = true
}
}
writeStdout([]byte(overlay.ClearAndDisable()))
SetCurrentAISuggestion(nil)
renderMu.Lock()
if renderTimer != nil {
renderTimer.Stop()
renderTimer = nil
}
renderMu.Unlock()
var cmdToSubmit string
if shouldAutoExecute {
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
s := strings.TrimSpace(selectedCmd)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selectedCmd = s
} else {
selectedCmd = s + " "
}
}
// update the line first
_, _ = ptmx.Write(append([]byte{0x15}, selectedCmd...))
cmdToSubmit = selectedCmd
} else {
bufferMu.Lock()
cmdToSubmit = naiveBuffer
bufferMu.Unlock()
}
if strings.TrimSpace(cmdToSubmit) == "iris reload" {
if newCfg, err := config.Load(); err == nil {
config.Init(newCfg)
disableGhostText.Store(!newCfg.UI.GhostText)
}
msg := "echo -e '\\033[32m✓ Iris configuration reloaded successfully.\\033[0m'\r"
_, _ = ptmx.Write(append([]byte{0x15}, []byte(msg)...))
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated.Store(false)
continue
}
integration.RecordSessionCommand(cmdToSubmit)
bufferMu.Lock()
lastSubmittedCommand = strings.TrimSpace(cmdToSubmit)
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
isCommandActive.Store(true)
_, _ = ptmx.Write([]byte{b}) // forward enter to terminal
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated.Store(false)
continue
}
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
inBracketedPaste = inputSlice[i+4] == '0'
logger.Debugf("Intercepted bracketed paste event inPaste=%v", inBracketedPaste)
_, _ = ptmx.Write(inputSlice[i : i+6])
i += 5
continue
}
}
isNavUp, navConsumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp)
if !isNavUp {
isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown)
}
if isNavUp || isNavDown {
intercepted = true
arrowDir := "down"
if isNavUp {
arrowDir = "up"
}
handleNavKey(arrowDir)
i += navConsumed - 1
continue
}
// handle escape sequences like arrow keys and functional shortcuts
// 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' {
_, navConsumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateRight)
if navConsumed == 0 {
if !intercepted {
writeStdout([]byte(overlay.ClearAndDisable()))
disableGhostText.Store(true)
isStandaloneEsc := n == 1 && b == '\033'
if !isStandaloneEsc {
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
}
_, _ = ptmx.Write([]byte{b})
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
}
i += navConsumed - 1
intercepted = true
bufferMu.Lock()
isEmptyQuery := naiveBuffer == "" && (!overlay.IsVisible() || overlay.GetTypedQuery() == "")
bufferMu.Unlock()
if isEmptyQuery {
continue
}
bufferMu.Lock()
atEnd := (cursorOffset == 0)
ghostText := ""
if !disableGhostText.Load() {
ghostText = overlay.GetGhostText(naiveBuffer, atEnd)
}
bufferMu.Unlock()
if len(ghostText) > 0 {
bufferMu.Lock()
naiveBuffer += ghostText
cursorOffset = 0
bufferMu.Unlock()
overlay.ClearGhostTextState()
_, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true
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)
isStandaloneEsc := n == 1 && b == '\033'
if !isLeftRightArrow && !isStandaloneEsc {
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
}
_, _ = ptmx.Write([]byte{b})
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 == 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()
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
disableGhostText.Store(false)
shouldOverlayDraw = false
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)
default:
// track normal printable characters in the buffer for matching
if b >= 32 && b <= 126 {
// expand alias on space, but only when typing manually (not pasting)
// and only if expand-alias configuration is enabled
bufferMu.Lock()
isSpaceAlias := config.Get().Core.ExpandAlias && !inBracketedPaste && 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 {
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
}
}
}