Files
iris-context/root/wrapper.go
T
VERSE 3d76a3ee88 feat: toml config support (#9)
## What's new
- TOML config support 
- add config init command to justfile
https://github.com/versenilvis/IRIS/pull/9/commits/a5bb6c23d74ca73385b3590d2eb275ce6429906b
- add copy binary file to local bin on reload
https://github.com/versenilvis/IRIS/pull/9/commits/4da3acdbc58c28f20fa0021d15fec82823ba392b
- init config on setup
https://github.com/versenilvis/IRIS/pull/9/commits/fb48b6bb832f38e24fab9211d92858bab9a58a07

## Fix
- add lsof to support macos because /proc filesystem is Linux-specific
and does not exist on macOS 0439d829573864fcf902fc4127f7d45384bfab01
- git push doesnt suggest current branch, fix
https://github.com/versenilvis/IRIS/pull/9/commits/b498532b401b764a4a1a2c70b3cb44b7caf72320
- use timeout context for lsof command in reload handler
https://github.com/versenilvis/IRIS/pull/9/commits/1a2baee951e6538a729da12aedfe53960e51102d
2026-05-30 22:05:06 +07:00

612 lines
16 KiB
Go

package root
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"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"
"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
)
// 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() {
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 10 (I chose 10 just because it won't conflict with other file descriptors)
c.ExtraFiles[10] = w
c.Env = adapter.GetEnv(10, 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
// put terminal in raw mode to intercept every keystroke
var errMakeRaw error
oldState, errMakeRaw = term.MakeRaw(int(os.Stdin.Fd()))
if errMakeRaw != nil {
panic(errMakeRaw)
}
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:
_ = 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, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", c.Process.Pid))
if err != 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:])
err = nil
break
}
}
}
}
if err == nil {
_ = os.Chdir(cwd)
}
_ = syscall.Kill(c.Process.Pid, syscall.SIGKILL)
_ = ptmx.Close()
}
restoreTerminal()
_ = syscall.Exec(exe, os.Args, os.Environ())
}
}
}()
overlay := integration.NewOverlay()
// start background update check (async)
pendingUpdate = startBackgroundUpdateCheck()
updatePrinted := false
// bridge pty output to actual stdout
go func() {
defer func() {
if r := recover(); r != nil {
WriteCrashLog(r)
restoreTerminal()
printCrashNotice()
startRescueShell()
os.Exit(2)
}
}()
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if err != nil {
if err == io.EOF {
restoreTerminal()
os.Exit(0)
}
continue
}
_, _ = os.Stdout.Write(buf[:n])
}
}()
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var userNavigated bool
var renderOverlay func()
isExecuting := func() bool {
pgrp, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP)
if err != nil {
return false
}
shellPGID, err := unix.Getpgid(core.ShellPID)
if err != nil {
return pgrp != core.ShellPID
}
return pgrp != shellPGID
}
// 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_STOP" {
// 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
}
if isExecuting() {
continue
}
results := MergeResults(query, "spec")
if len(results) == 0 {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
continue
}
_, _ = os.Stdout.Write([]byte(overlay.Clear()))
overlay.UpdateItems(results)
var rBuf strings.Builder
if !disableGhostText.Load() {
rBuf.WriteString(overlay.RenderGhostText(query, false))
}
rBuf.WriteString(overlay.Render())
_, _ = os.Stdout.Write([]byte(rBuf.String()))
}
}()
var naiveBuffer string
suggestionsEnabled := true
mode := loadMode()
var renderTimer *time.Timer
var renderMu sync.Mutex
// renderOverlay decides whether to draw the suggestion menu based on current state
renderOverlay = func() {
renderMu.Lock()
defer renderMu.Unlock()
if renderTimer != nil {
renderTimer.Stop()
}
if !suggestionsEnabled || isExecuting() {
return
}
bufCopy := naiveBuffer
modeCopy := mode
navCopy := userNavigated
if bufCopy == "" && !navCopy {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
return
}
// Debounce for 15ms to allow PTY to process the keystroke and update the terminal cursor.
// This completely prevents the asynchronous ghost text race condition where the PTY echo
// overwrites the first letter of our ghost text!
renderTimer = time.AfterFunc(15*time.Millisecond, func() {
if isExecuting() {
return
}
var b strings.Builder
if !navCopy {
debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy)
results := MergeResults(bufCopy, modeCopy)
debugLog("[Render] results found: %d", len(results))
if len(results) == 0 {
b.WriteString(overlay.ClearAndDisable())
_, _ = os.Stdout.Write([]byte(b.String()))
return
}
if overlay.Visible {
b.WriteString(overlay.Clear())
}
overlay.UpdateItems(results)
} else {
if overlay.Visible {
b.WriteString(overlay.Clear())
}
}
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy))
}
b.WriteString(overlay.Render())
_, _ = os.Stdout.Write([]byte(b.String()))
})
}
renderOverlay()
renderNow := func() {
renderMu.Lock()
if renderTimer != nil {
renderTimer.Stop()
}
renderMu.Unlock()
var b strings.Builder
if overlay.Visible {
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(naiveBuffer, userNavigated))
}
b.WriteString(overlay.Render())
}
_, _ = os.Stdout.Write([]byte(b.String()))
}
// 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
}
shouldOverlayDraw := false
for i := 0; i < n; i++ {
b := inputSlice[i]
intercepted := false
if b == '\033' {
// 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
if !suggestionsEnabled {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += 2
continue
}
if overlay.Visible && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') {
intercepted = true
userNavigated = true
_, _ = os.Stdout.Write([]byte(overlay.Clear())) // clear old menu
if inputSlice[i+2] == 'A' { // up arrow
overlay.Cursor--
if overlay.Cursor < 0 {
overlay.Cursor = 0
}
} else { // down arrow
overlay.Cursor++
if overlay.Cursor >= len(overlay.Items) {
overlay.Cursor = len(overlay.Items) - 1
}
}
selected := overlay.Items[overlay.Cursor].Cmd
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line
_, _ = ptmx.Write([]byte(selected))
naiveBuffer = selected
renderNow()
i += 2
continue
} else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt
intercepted = true
mode = "history"
saveMode(mode)
results := MergeResults("", "history")
if len(results) > 0 {
limit := 100
if len(results) < limit {
limit = len(results)
}
var historyList []core.Suggestion
if inputSlice[i+2] == 'A' {
// Up arrow: Reverse the list so newest is at the bottom
for j := limit - 1; j >= 0; j-- {
historyList = append(historyList, results[j])
}
} else {
// Down arrow: Normal order, newest is at the top
for j := 0; j < limit; j++ {
historyList = append(historyList, results[j])
}
}
overlay.UpdateItems(historyList)
if inputSlice[i+2] == 'A' {
overlay.Cursor = len(historyList) - 1 // Up arrow: Start at the bottom
} else {
overlay.Cursor = 0 // Down arrow: Start at the top
}
selected := overlay.Items[overlay.Cursor].Cmd
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line
_, _ = ptmx.Write([]byte(selected))
naiveBuffer = selected
userNavigated = true
renderNow()
}
i += 2
continue
} else if overlay.Visible && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
topCmd := overlay.Items[0].Cmd
if strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) {
ghostText := topCmd[len(naiveBuffer):]
if len(ghostText) > 0 {
intercepted = true
naiveBuffer += ghostText
_, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true
i += 2
continue
}
}
}
}
// forward escape sequence to pty if not intercepted
if !intercepted {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
disableGhostText.Store(true)
naiveBuffer = ""
_, _ = 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
if mode == "spec" {
mode = "history"
} else {
mode = "spec"
}
saveMode(mode)
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 overlay.Visible && (b == 0x0d || b == 0x0a) {
intercepted = true
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
_, _ = ptmx.Write([]byte{0x0d})
naiveBuffer = ""
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated = false
continue
} else if b == 0x09 { // tab: select suggestions
intercepted = true
if !overlay.Visible {
shouldOverlayDraw = true
} else {
selected := overlay.Items[overlay.Cursor].Cmd
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
if mode == "spec" {
selected = strings.TrimSpace(selected) + " "
}
naiveBuffer = selected
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line
_, _ = ptmx.Write([]byte(selected))
overlay.Cursor = 0 // 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 = 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 127, 0x08: // backspace: remove last character from buffer
if len(naiveBuffer) > 0 {
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1]
shouldOverlayDraw = true
userNavigated = false
}
case 0x17: // ctrl+w: delete the last word in the buffer
trimBuf := strings.TrimRight(naiveBuffer, " ")
lastSpace := strings.LastIndex(trimBuf, " ")
if lastSpace >= 0 {
naiveBuffer = trimBuf[:lastSpace+1]
} else {
naiveBuffer = ""
}
shouldOverlayDraw = true
userNavigated = false
case '\r', '\n', 0x03, 0x15, 0x0C: // enter, ctrl+c, ctrl+u, ctrl+l: clear buffer on line reset
naiveBuffer = ""
disableGhostText.Store(false)
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
userNavigated = 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
if b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") {
if target, ok := core.GetAlias(naiveBuffer); ok {
// clear the current alias and replace it with the full command
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear the current input line
_, _ = ptmx.Write([]byte(target + " "))
naiveBuffer = target + " "
shouldOverlayDraw = true
continue
}
}
naiveBuffer += string(b)
shouldOverlayDraw = true
}
}
}
}
if shouldOverlayDraw {
renderOverlay()
}
}
}
}