refactor(root): seperate into small files
This commit is contained in:
+2
-457
@@ -1,18 +1,9 @@
|
|||||||
package root
|
package root
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/creack/pty"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/versenilvis/iris/commands/core"
|
"github.com/versenilvis/iris/commands/core"
|
||||||
_ "github.com/versenilvis/iris/commands/dev"
|
_ "github.com/versenilvis/iris/commands/dev"
|
||||||
@@ -21,9 +12,6 @@ import (
|
|||||||
_ "github.com/versenilvis/iris/commands/runner"
|
_ "github.com/versenilvis/iris/commands/runner"
|
||||||
_ "github.com/versenilvis/iris/commands/search"
|
_ "github.com/versenilvis/iris/commands/search"
|
||||||
_ "github.com/versenilvis/iris/commands/view"
|
_ "github.com/versenilvis/iris/commands/view"
|
||||||
"github.com/versenilvis/iris/integration"
|
|
||||||
"github.com/versenilvis/iris/integration/shell"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -36,8 +24,8 @@ It works exactly like coding editor suggestion menu drop down.`,
|
|||||||
if debugMode {
|
if debugMode {
|
||||||
f, _ := os.OpenFile("iris.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
f, _ := os.OpenFile("iris.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||||
debugLogger = f
|
debugLogger = f
|
||||||
core.DebugWriter = f // Link core logger
|
core.DebugWriter = f
|
||||||
fmt.Fprintf(debugLogger, "--- IRIS Debug Started ---\n")
|
fmt.Fprintf(debugLogger, "--- IRIS DEBUG LOG ---\n")
|
||||||
}
|
}
|
||||||
runWrapper()
|
runWrapper()
|
||||||
},
|
},
|
||||||
@@ -71,446 +59,3 @@ func Execute() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectShell() string {
|
|
||||||
pid := os.Getppid()
|
|
||||||
for i := 0; i < 5 && pid > 1; i++ {
|
|
||||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
|
||||||
if err == nil {
|
|
||||||
comm := strings.ToLower(strings.TrimSpace(string(data)))
|
|
||||||
if strings.Contains(comm, "zsh") {
|
|
||||||
return "zsh"
|
|
||||||
}
|
|
||||||
if strings.Contains(comm, "bash") {
|
|
||||||
return "bash"
|
|
||||||
}
|
|
||||||
if strings.Contains(comm, "fish") {
|
|
||||||
return "fish"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err = os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
fields := strings.Fields(string(data))
|
|
||||||
if len(fields) > 3 {
|
|
||||||
ppid, _ := strconv.Atoi(fields[3])
|
|
||||||
if ppid == pid || ppid <= 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pid = ppid
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s := os.Getenv("SHELL")
|
|
||||||
if strings.Contains(s, "zsh") {
|
|
||||||
return "zsh"
|
|
||||||
}
|
|
||||||
return "bash"
|
|
||||||
}
|
|
||||||
|
|
||||||
type procInfo struct {
|
|
||||||
pid int
|
|
||||||
ppid int
|
|
||||||
comm string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getActiveInnerShell(rootPid int, defaultShell string) string {
|
|
||||||
cmd := exec.Command("ps", "-e", "-o", "pid,ppid,comm")
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return defaultShell
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(string(out), "\n")
|
|
||||||
childrenMap := make(map[int][]procInfo)
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 3 && fields[0] != "PID" {
|
|
||||||
pid, _ := strconv.Atoi(fields[0])
|
|
||||||
ppid, _ := strconv.Atoi(fields[1])
|
|
||||||
comm := strings.ToLower(strings.Join(fields[2:], " "))
|
|
||||||
childrenMap[ppid] = append(childrenMap[ppid], procInfo{pid, ppid, comm})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var findDeepest func(pid int, current string) string
|
|
||||||
findDeepest = func(pid int, current string) string {
|
|
||||||
shell := current
|
|
||||||
for _, child := range childrenMap[pid] {
|
|
||||||
childShell := shell
|
|
||||||
if strings.Contains(child.comm, "zsh") {
|
|
||||||
childShell = "zsh"
|
|
||||||
}
|
|
||||||
if strings.Contains(child.comm, "bash") {
|
|
||||||
childShell = "bash"
|
|
||||||
}
|
|
||||||
if strings.Contains(child.comm, "fish") {
|
|
||||||
childShell = "fish"
|
|
||||||
}
|
|
||||||
if deepest := findDeepest(child.pid, childShell); deepest != "" {
|
|
||||||
shell = deepest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return shell
|
|
||||||
}
|
|
||||||
return findDeepest(rootPid, defaultShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWrapper() {
|
|
||||||
r, w, err := os.Pipe()
|
|
||||||
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
|
|
||||||
|
|
||||||
c := exec.Command(adapter.GetShellPath())
|
|
||||||
c.ExtraFiles = make([]*os.File, 11)
|
|
||||||
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 ptmx.Close()
|
|
||||||
|
|
||||||
_ = pty.InheritSize(os.Stdin, ptmx)
|
|
||||||
core.ShellPID = c.Process.Pid
|
|
||||||
|
|
||||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 2)
|
|
||||||
signal.Notify(sigCh, syscall.SIGWINCH, syscall.SIGUSR1)
|
|
||||||
go func() {
|
|
||||||
for s := range sigCh {
|
|
||||||
switch s {
|
|
||||||
case syscall.SIGWINCH:
|
|
||||||
_ = pty.InheritSize(os.Stdin, ptmx)
|
|
||||||
case syscall.SIGUSR1:
|
|
||||||
exe, _ := os.Executable()
|
|
||||||
os.Setenv("IRIS_RELOADED", "true")
|
|
||||||
|
|
||||||
// capture the current shell state
|
|
||||||
innerShell := getActiveInnerShell(c.Process.Pid, shellName)
|
|
||||||
if innerShell != "" {
|
|
||||||
os.Setenv("IRIS_ACTIVE_SHELL", innerShell)
|
|
||||||
}
|
|
||||||
|
|
||||||
// kill the child process before reloading
|
|
||||||
if c.Process != nil {
|
|
||||||
_ = syscall.Kill(c.Process.Pid, syscall.SIGKILL)
|
|
||||||
ptmx.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
if oldState != nil {
|
|
||||||
_ = term.Restore(int(os.Stdin.Fd()), oldState)
|
|
||||||
}
|
|
||||||
_ = syscall.Exec(exe, os.Args, os.Environ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
overlay := integration.NewOverlay()
|
|
||||||
|
|
||||||
// pty -> stdout
|
|
||||||
go func() {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := ptmx.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
_ = term.Restore(int(os.Stdin.Fd()), oldState)
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
os.Stdout.Write(buf[:n])
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// ipc pipe
|
|
||||||
go func() {
|
|
||||||
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()
|
|
||||||
results := mergeResults(query, "spec")
|
|
||||||
if len(results) == 0 {
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
os.Stdout.Write([]byte(overlay.Clear()))
|
|
||||||
overlay.UpdateItems(results)
|
|
||||||
os.Stdout.Write([]byte(overlay.Render()))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var naiveBuffer string
|
|
||||||
suggestionsEnabled := true
|
|
||||||
mode := "spec"
|
|
||||||
|
|
||||||
renderOverlay := func() {
|
|
||||||
if !suggestionsEnabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If buffer is empty (e.g. backspace to 0 or after reload), clear overlay immediately
|
|
||||||
if naiveBuffer == "" {
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode)
|
|
||||||
results := mergeResults(naiveBuffer, mode)
|
|
||||||
debugLog("[Render] results found: %d", len(results))
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
} else {
|
|
||||||
var buf strings.Builder
|
|
||||||
if overlay.Visible {
|
|
||||||
buf.WriteString(overlay.Clear())
|
|
||||||
}
|
|
||||||
overlay.UpdateItems(results)
|
|
||||||
buf.WriteString(overlay.Render())
|
|
||||||
os.Stdout.Write([]byte(buf.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger initial render to clear any potential artifacts from previous session
|
|
||||||
renderOverlay()
|
|
||||||
|
|
||||||
for {
|
|
||||||
inputSlice := make([]byte, 128)
|
|
||||||
n, err := os.Stdin.Read(inputSlice)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if n > 0 {
|
|
||||||
shouldOverlayDraw := false
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
b := inputSlice[i]
|
|
||||||
intercepted := false
|
|
||||||
|
|
||||||
// Detect Escape sequence (e.g. arrows, Shift+Tab) early
|
|
||||||
if b == '\033' {
|
|
||||||
// Shift+Tab: \033[Z
|
|
||||||
if i+2 < n && 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
|
|
||||||
}
|
|
||||||
|
|
||||||
ptmx.Write([]byte{b})
|
|
||||||
// arrow key monitoring inside escape
|
|
||||||
if i+2 < n && inputSlice[i+1] == '[' {
|
|
||||||
if overlay.Visible {
|
|
||||||
switch inputSlice[i+2] {
|
|
||||||
case 'A': // up
|
|
||||||
overlay.Cursor--
|
|
||||||
if overlay.Cursor < 0 {
|
|
||||||
overlay.Cursor = 0
|
|
||||||
}
|
|
||||||
os.Stdout.Write([]byte(overlay.Render()))
|
|
||||||
case 'B': // down
|
|
||||||
overlay.Cursor++
|
|
||||||
if overlay.Cursor >= len(overlay.Items) {
|
|
||||||
overlay.Cursor = len(overlay.Items) - 1
|
|
||||||
}
|
|
||||||
os.Stdout.Write([]byte(overlay.Render()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// skip sequence
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iris shortcuts
|
|
||||||
if b == 0x12 { // ctrl+r
|
|
||||||
intercepted = true
|
|
||||||
if mode == "spec" {
|
|
||||||
mode = "history"
|
|
||||||
} else {
|
|
||||||
mode = "spec"
|
|
||||||
}
|
|
||||||
shouldOverlayDraw = true
|
|
||||||
} else if b == 0x1b && n == 1 { // Standalone ESC (Dismiss CURRENT menu only)
|
|
||||||
intercepted = true
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
shouldOverlayDraw = false // Just hide it for this render
|
|
||||||
continue
|
|
||||||
} else if overlay.Visible && (b == 0x0d || b == 0x0a) { // Enter while menu open
|
|
||||||
intercepted = true
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
// Send Enter to PTY to execute current buffer as-is
|
|
||||||
ptmx.Write([]byte{0x0d})
|
|
||||||
naiveBuffer = ""
|
|
||||||
shouldOverlayDraw = false
|
|
||||||
continue
|
|
||||||
} else if b == 0x09 { // Tab (Universal Intercept - Select/Commit)
|
|
||||||
intercepted = true
|
|
||||||
if !overlay.Visible {
|
|
||||||
debugLog("[Input] Tab pressed: opening menu")
|
|
||||||
shouldOverlayDraw = true
|
|
||||||
} else {
|
|
||||||
// SELECT and COMMIT
|
|
||||||
selected := overlay.Items[overlay.Cursor].Cmd
|
|
||||||
debugLog("[Input] Tab pressed: committing '%s'", selected)
|
|
||||||
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
|
|
||||||
// Auto-add space for spec mode
|
|
||||||
if mode == "spec" {
|
|
||||||
selected = strings.TrimSpace(selected) + " "
|
|
||||||
}
|
|
||||||
|
|
||||||
naiveBuffer = selected
|
|
||||||
mode = "spec"
|
|
||||||
// Sync PTY with the new buffer (Ctrl+A then Ctrl+K to clear rest)
|
|
||||||
ptmx.Write([]byte{0x01, 0x0b}) // bash/zsh style home and clear
|
|
||||||
ptmx.Write([]byte(selected))
|
|
||||||
|
|
||||||
shouldOverlayDraw = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// normal typing
|
|
||||||
if !intercepted {
|
|
||||||
ptmx.Write([]byte{b})
|
|
||||||
switch b {
|
|
||||||
case 127, 0x08: // backspace
|
|
||||||
if len(naiveBuffer) > 0 {
|
|
||||||
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1]
|
|
||||||
shouldOverlayDraw = true
|
|
||||||
}
|
|
||||||
case 0x17: // Ctrl+W: delete one word
|
|
||||||
trimBuf := strings.TrimRight(naiveBuffer, " ")
|
|
||||||
lastSpace := strings.LastIndex(trimBuf, " ")
|
|
||||||
if lastSpace >= 0 {
|
|
||||||
naiveBuffer = trimBuf[:lastSpace+1]
|
|
||||||
} else {
|
|
||||||
naiveBuffer = ""
|
|
||||||
}
|
|
||||||
shouldOverlayDraw = true
|
|
||||||
case '\r', 0x03, 0x15, 0x0C: // Enter, Ctrl+C, Ctrl+U, Ctrl+L
|
|
||||||
naiveBuffer = ""
|
|
||||||
mode = "spec"
|
|
||||||
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
|
||||||
default:
|
|
||||||
if b >= 32 && b <= 126 {
|
|
||||||
naiveBuffer += string(b)
|
|
||||||
shouldOverlayDraw = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if shouldOverlayDraw {
|
|
||||||
renderOverlay()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeResults(query string, mode string) []core.Suggestion {
|
|
||||||
if query == "" {
|
|
||||||
debugLog("[Merge] Query empty, returning nil")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedQuery := strings.TrimSpace(query)
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
deduped := []core.Suggestion{}
|
|
||||||
|
|
||||||
if mode == "history" {
|
|
||||||
histResults, _ := integration.SearchHistory(query)
|
|
||||||
for _, h := range histResults {
|
|
||||||
normalizedCmd := strings.TrimSpace(h.Cmd)
|
|
||||||
if seen[normalizedCmd] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[normalizedCmd] = true
|
|
||||||
deduped = append(deduped, core.Suggestion{
|
|
||||||
Cmd: h.Cmd,
|
|
||||||
Desc: " history",
|
|
||||||
Icon: fmt.Sprintf("%d", h.ID),
|
|
||||||
})
|
|
||||||
if len(deduped) >= 10 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debugLog("[Merge] History mode found %d items", len(deduped))
|
|
||||||
return deduped
|
|
||||||
}
|
|
||||||
|
|
||||||
debugLog("[Merge] Calling Lookup for '%s'", query)
|
|
||||||
cmdResults := core.Lookup(query)
|
|
||||||
debugLog("[Merge] Lookup returned %d raw items", len(cmdResults))
|
|
||||||
|
|
||||||
for _, s := range cmdResults {
|
|
||||||
normalizedCmd := strings.TrimSpace(s.Cmd)
|
|
||||||
// CRITICAL: Filter out exact matches to prevent infinite tab loops
|
|
||||||
if normalizedCmd == normalizedQuery {
|
|
||||||
debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !seen[s.Cmd] {
|
|
||||||
seen[s.Cmd] = true
|
|
||||||
deduped = append(deduped, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(deduped) > 10 {
|
|
||||||
deduped = deduped[:10]
|
|
||||||
}
|
|
||||||
return deduped
|
|
||||||
}
|
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package root
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type procInfo struct {
|
||||||
|
pid int
|
||||||
|
ppid int
|
||||||
|
comm string
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectShell identifies the shell type by scanning parent processes
|
||||||
|
func detectShell() string {
|
||||||
|
pid := os.Getppid()
|
||||||
|
for i := 0; i < 5 && pid > 1; i++ {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
if err == nil {
|
||||||
|
comm := strings.ToLower(strings.TrimSpace(string(data)))
|
||||||
|
if strings.Contains(comm, "zsh") {
|
||||||
|
return "zsh"
|
||||||
|
}
|
||||||
|
if strings.Contains(comm, "bash") {
|
||||||
|
return "bash"
|
||||||
|
}
|
||||||
|
if strings.Contains(comm, "fish") {
|
||||||
|
return "fish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err = os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) > 3 {
|
||||||
|
ppid, _ := strconv.Atoi(fields[3])
|
||||||
|
if ppid == pid || ppid <= 1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pid = ppid
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := os.Getenv("SHELL")
|
||||||
|
if strings.Contains(s, "zsh") {
|
||||||
|
return "zsh"
|
||||||
|
}
|
||||||
|
return "bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveInnerShell retrieves the most recently started shell in the process tree
|
||||||
|
// example: getActiveInnerShell(1234, "bash")
|
||||||
|
func getActiveInnerShell(rootPid int, defaultShell string) string {
|
||||||
|
cmd := exec.Command("ps", "-e", "-o", "pid,ppid,comm")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return defaultShell
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
childrenMap := make(map[int][]procInfo)
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 3 && fields[0] != "PID" {
|
||||||
|
pid, _ := strconv.Atoi(fields[0])
|
||||||
|
ppid, _ := strconv.Atoi(fields[1])
|
||||||
|
comm := strings.ToLower(strings.Join(fields[2:], " "))
|
||||||
|
childrenMap[ppid] = append(childrenMap[ppid], procInfo{pid, ppid, comm})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var findDeepest func(pid int, current string) string
|
||||||
|
findDeepest = func(pid int, current string) string {
|
||||||
|
shell := current
|
||||||
|
for _, child := range childrenMap[pid] {
|
||||||
|
childShell := shell
|
||||||
|
if strings.Contains(child.comm, "zsh") {
|
||||||
|
childShell = "zsh"
|
||||||
|
}
|
||||||
|
if strings.Contains(child.comm, "bash") {
|
||||||
|
childShell = "bash"
|
||||||
|
}
|
||||||
|
if strings.Contains(child.comm, "fish") {
|
||||||
|
childShell = "fish"
|
||||||
|
}
|
||||||
|
if deepest := findDeepest(child.pid, childShell); deepest != "" {
|
||||||
|
shell = deepest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
return findDeepest(rootPid, defaultShell)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package root
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/versenilvis/iris/commands/core"
|
||||||
|
"github.com/versenilvis/iris/integration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mergeResults collects and dedupes suggestions for a query and mode
|
||||||
|
// example: mergeResults("git ", "spec")
|
||||||
|
func mergeResults(query string, mode string) []core.Suggestion {
|
||||||
|
if query == "" {
|
||||||
|
debugLog("[Merge] Query empty, returning nil")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedQuery := strings.TrimSpace(query)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
deduped := []core.Suggestion{}
|
||||||
|
|
||||||
|
if mode == "history" {
|
||||||
|
histResults, _ := integration.SearchHistory(query)
|
||||||
|
for _, h := range histResults {
|
||||||
|
normalizedCmd := strings.TrimSpace(h.Cmd)
|
||||||
|
if seen[normalizedCmd] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalizedCmd] = true
|
||||||
|
deduped = append(deduped, core.Suggestion{
|
||||||
|
Cmd: h.Cmd,
|
||||||
|
Desc: " history",
|
||||||
|
Icon: fmt.Sprintf("%d", h.ID),
|
||||||
|
})
|
||||||
|
if len(deduped) >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog("[Merge] History mode found %d items", len(deduped))
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog("[Merge] Calling Lookup for '%s'", query)
|
||||||
|
cmdResults := core.Lookup(query)
|
||||||
|
debugLog("[Merge] Lookup returned %d raw items", len(cmdResults))
|
||||||
|
|
||||||
|
for _, s := range cmdResults {
|
||||||
|
normalizedCmd := strings.TrimSpace(s.Cmd)
|
||||||
|
if normalizedCmd == normalizedQuery { // filter exact matches to avoid loops
|
||||||
|
debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !seen[s.Cmd] {
|
||||||
|
seen[s.Cmd] = true
|
||||||
|
deduped = append(deduped, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(deduped) > 10 {
|
||||||
|
deduped = deduped[:10]
|
||||||
|
}
|
||||||
|
return deduped
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
package root
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/versenilvis/iris/commands/core"
|
||||||
|
"github.com/versenilvis/iris/integration"
|
||||||
|
"github.com/versenilvis/iris/integration/shell"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
c := exec.Command(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) // start shell in a pseudo-terminal
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[IRIS] failed to start PTY: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
_ = pty.InheritSize(os.Stdin, ptmx)
|
||||||
|
core.ShellPID = c.Process.Pid
|
||||||
|
|
||||||
|
// put terminal in raw mode to intercept every keystroke
|
||||||
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 2)
|
||||||
|
signal.Notify(sigCh, syscall.SIGWINCH, syscall.SIGUSR1)
|
||||||
|
go func() {
|
||||||
|
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 mannually
|
||||||
|
// 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()
|
||||||
|
// this marks for the next iris process that it've just reloaded
|
||||||
|
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 {
|
||||||
|
_ = syscall.Kill(c.Process.Pid, syscall.SIGKILL)
|
||||||
|
ptmx.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldState != nil {
|
||||||
|
_ = term.Restore(int(os.Stdin.Fd()), oldState)
|
||||||
|
}
|
||||||
|
_ = syscall.Exec(exe, os.Args, os.Environ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
overlay := integration.NewOverlay()
|
||||||
|
|
||||||
|
// bridge pty output to actual stdout
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := ptmx.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
_ = term.Restore(int(os.Stdin.Fd()), oldState)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
os.Stdout.Write(buf[:n])
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// listen for suggestion requests from shell scripts via the ipc pipe
|
||||||
|
go func() {
|
||||||
|
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()
|
||||||
|
results := mergeResults(query, "spec")
|
||||||
|
if len(results) == 0 {
|
||||||
|
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
os.Stdout.Write([]byte(overlay.Clear()))
|
||||||
|
overlay.UpdateItems(results)
|
||||||
|
os.Stdout.Write([]byte(overlay.Render()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var naiveBuffer string
|
||||||
|
suggestionsEnabled := true
|
||||||
|
mode := "spec"
|
||||||
|
|
||||||
|
// renderOverlay decides whether to draw the suggestion menu based on current state
|
||||||
|
renderOverlay := func() {
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if naiveBuffer == "" {
|
||||||
|
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode)
|
||||||
|
results := mergeResults(naiveBuffer, mode)
|
||||||
|
debugLog("[Render] results found: %d", len(results))
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
||||||
|
} else {
|
||||||
|
var buf strings.Builder
|
||||||
|
if overlay.Visible {
|
||||||
|
buf.WriteString(overlay.Clear())
|
||||||
|
}
|
||||||
|
overlay.UpdateItems(results)
|
||||||
|
buf.WriteString(overlay.Render())
|
||||||
|
os.Stdout.Write([]byte(buf.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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+2] == 'Z' {
|
||||||
|
// shift tab: hide/unhide menu dropdown
|
||||||
|
intercepted = true
|
||||||
|
suggestionsEnabled = !suggestionsEnabled
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
||||||
|
} else {
|
||||||
|
shouldOverlayDraw = true
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx.Write([]byte{b})
|
||||||
|
if i+2 < n && inputSlice[i+1] == '[' {
|
||||||
|
if overlay.Visible {
|
||||||
|
switch inputSlice[i+2] {
|
||||||
|
case 'A': // up arrow
|
||||||
|
overlay.Cursor--
|
||||||
|
if overlay.Cursor < 0 {
|
||||||
|
overlay.Cursor = 0
|
||||||
|
}
|
||||||
|
os.Stdout.Write([]byte(overlay.Render()))
|
||||||
|
case 'B': // down arrow
|
||||||
|
overlay.Cursor++
|
||||||
|
if overlay.Cursor >= len(overlay.Items) {
|
||||||
|
overlay.Cursor = len(overlay.Items) - 1
|
||||||
|
}
|
||||||
|
os.Stdout.Write([]byte(overlay.Render()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
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 = ""
|
||||||
|
shouldOverlayDraw = 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
|
||||||
|
mode = "spec"
|
||||||
|
|
||||||
|
ptmx.Write([]byte{0x15}) // ctrl+u to clear line
|
||||||
|
ptmx.Write([]byte(selected))
|
||||||
|
|
||||||
|
shouldOverlayDraw = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !intercepted {
|
||||||
|
ptmx.Write([]byte{b})
|
||||||
|
switch b {
|
||||||
|
case 127, 0x08: // backspace: remove last character from buffer
|
||||||
|
if len(naiveBuffer) > 0 {
|
||||||
|
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1]
|
||||||
|
shouldOverlayDraw = true
|
||||||
|
}
|
||||||
|
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
|
||||||
|
case '\r', 0x03, 0x15, 0x0C: // enter, ctrl+c, ctrl+u, ctrl+l: clear buffer on line reset
|
||||||
|
naiveBuffer = ""
|
||||||
|
mode = "spec"
|
||||||
|
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
||||||
|
default:
|
||||||
|
// track normal printable characters in the buffer for matching
|
||||||
|
if b >= 32 && b <= 126 {
|
||||||
|
naiveBuffer += string(b)
|
||||||
|
shouldOverlayDraw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shouldOverlayDraw {
|
||||||
|
renderOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user