diff --git a/internal/tool/bash/bash.go b/internal/tool/bash/bash.go index fe3a322..9d75047 100644 --- a/internal/tool/bash/bash.go +++ b/internal/tool/bash/bash.go @@ -88,6 +88,15 @@ func (t *Tool) Execute(ctx context.Context, args json.RawMessage) (tool.Result, command = t.aliases.ExpandCommand(command) } + // Interactive detection: bail before security checks so the user gets + // a helpful message rather than a timeout or security error. + if reason := isInteractiveCmd(command); reason != "" { + return tool.Result{ + Output: fmt.Sprintf("%s\n(%s)", interactiveHint, reason), + Metadata: map[string]any{"interactive": true}, + }, nil + } + // Security validation runs on the expanded command if violation := ValidateCommand(command); violation != nil { return tool.Result{ diff --git a/internal/tool/bash/bash_test.go b/internal/tool/bash/bash_test.go index 8407b63..f00dfca 100644 --- a/internal/tool/bash/bash_test.go +++ b/internal/tool/bash/bash_test.go @@ -121,6 +121,37 @@ func TestBashTool_WorkingDir(t *testing.T) { } } +func TestBashTool_InteractiveDetection(t *testing.T) { + b := New() + + tests := []struct { + name string + cmd string + wantHit bool + }{ + {"sudo", "sudo apt install vim", true}, + {"vim", "vim file.txt", true}, + {"git push", "git push origin main", true}, + {"python3 REPL", "python3", true}, + {"python3 script not interactive", "python3 script.py", false}, + {"ls not interactive", "ls -la", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, _ := json.Marshal(map[string]string{"command": tt.cmd}) + result, err := b.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute: %v", err) + } + hit := result.Metadata["interactive"] == true + if hit != tt.wantHit { + t.Errorf("interactive=%v, want %v (output=%q)", hit, tt.wantHit, result.Output) + } + }) + } +} + func TestBashTool_ContextCancellation(t *testing.T) { b := New() ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/tool/bash/interactive.go b/internal/tool/bash/interactive.go new file mode 100644 index 0000000..90eb83d --- /dev/null +++ b/internal/tool/bash/interactive.go @@ -0,0 +1,61 @@ +package bash + +import "strings" + +// interactiveHint is the tool result output returned when an interactive command is detected. +const interactiveHint = "Command requires interactive terminal input.\n" + + "Use /shell in the TUI to open a shell, or /shell to run this command directly." + +// isInteractiveCmd reports whether cmd needs an interactive terminal to function. +// Returns a non-empty reason string when the command is interactive, "" otherwise. +func isInteractiveCmd(cmd string) string { + lower := strings.ToLower(strings.TrimSpace(cmd)) + + // prefixInteractive: always interactive regardless of arguments. + for _, p := range prefixInteractive { + if lower == p || strings.HasPrefix(lower, p+" ") || strings.HasPrefix(lower, p+"\t") { + return p + " requires an interactive terminal" + } + } + + // exactInteractive: interactive only when invoked as a bare command (REPL mode). + for _, p := range exactInteractive { + if lower == p { + return p + " opens an interactive REPL" + } + } + + return "" +} + +// prefixInteractive lists commands that are interactive regardless of their arguments. +var prefixInteractive = []string{ + "sudo", + "ssh", + "passwd", + "vim", + "vi", + "nano", + "less", + "more", + "htop", + "top", + "mysql", + "psql", + "ftp", + "sftp", + "git push", +} + +// exactInteractive lists commands that are interactive only when invoked without arguments +// (i.e., they open a REPL). With arguments they run scripts non-interactively. +var exactInteractive = []string{ + "python3", + "python", + "python2", + "node", + "irb", + "iex", + "ghci", + "julia", +} diff --git a/internal/tool/bash/interactive_test.go b/internal/tool/bash/interactive_test.go new file mode 100644 index 0000000..4dca167 --- /dev/null +++ b/internal/tool/bash/interactive_test.go @@ -0,0 +1,82 @@ +package bash + +import "testing" + +func TestIsInteractiveCmd(t *testing.T) { + tests := []struct { + cmd string + wantHit bool + }{ + // prefix-interactive: always interactive with any args + {"sudo apt install foo", true}, + {"sudo", true}, + {"sudo\tls", true}, + {"ssh user@host", true}, + {"ssh -p 2222 host", true}, + {"ssh", true}, + {"passwd", true}, + {"passwd root", true}, + {"vim file.txt", true}, + {"vim", true}, + {"vi /etc/hosts", true}, + {"vi", true}, + {"nano config.yml", true}, + {"nano", true}, + {"less output.log", true}, + {"more pager.txt", true}, + {"htop", true}, + {"top", true}, + {"top -b", true}, + {"mysql -u root", true}, + {"psql -U postgres", true}, + {"ftp example.com", true}, + {"sftp user@host", true}, + {"git push origin main", true}, + {"git push", true}, + + // exact-interactive: interactive only as bare command (REPL) + {"python3", true}, + {"python", true}, + {"python2", true}, + {"node", true}, + {"irb", true}, + {"iex", true}, + {"ghci", true}, + {"julia", true}, + + // exact-interactive: args → NOT interactive (running a script) + {"python3 script.py", false}, + {"python3 -c 'print(1)'", false}, + {"python2 run.py", false}, + {"node server.js", false}, + {"irb file.rb", false}, + {"julia script.jl", false}, + + // non-interactive commands + {"ls -la", false}, + {"grep foo bar.txt", false}, + {"cat /etc/hosts", false}, + {"git status", false}, + {"git pull", false}, + {"git diff", false}, + {"echo hello", false}, + {"pwd", false}, + {"find . -name '*.go'", false}, + + // should not match on prefix-sharing names + {"topological_sort", false}, + {"sshkeys", false}, + {"vitals", false}, + {"moral", false}, + {"git pushover", false}, + } + + for _, tt := range tests { + reason := isInteractiveCmd(tt.cmd) + got := reason != "" + if got != tt.wantHit { + t.Errorf("isInteractiveCmd(%q) hit=%v (reason=%q), want hit=%v", + tt.cmd, got, reason, tt.wantHit) + } + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index f9f1c13..d20f4fd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -47,6 +47,7 @@ type elfProgressMsg struct{ progress elf.Progress } type modelUpdatedMsg struct{} // sent when background discovery reconciles the model name type clearQuitHintMsg struct{} type resumeListLoadedMsg struct{ sessions []session.Metadata } +type shellExitMsg struct{ err error } type chatMessage struct { role string @@ -428,6 +429,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case shellExitMsg: + if msg.err != nil { + m.messages = append(m.messages, chatMessage{role: "error", + content: "shell exited with error: " + msg.err.Error()}) + } else { + m.messages = append(m.messages, chatMessage{role: "system", content: "shell session ended"}) + } + return m, nil + case clearQuitHintMsg: m.quitHint = false return m, nil @@ -890,9 +900,16 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { return m, nil case "/shell": - m.messages = append(m.messages, chatMessage{role: "system", - content: "interactive shell not yet implemented\nFor now, use ! prefix in your terminal: ! sudo command"}) - return m, nil + shell := shellExe() + var cmd *exec.Cmd + if args != "" { + cmd = exec.Command(shell, "-c", args) + } else { + cmd = exec.Command(shell) + } + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { + return shellExitMsg{err: err} + }) case "/permission", "/perm": if m.config.Permissions == nil { @@ -1056,7 +1073,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { case "/help": m.messages = append(m.messages, chatMessage{role: "system", - content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) + content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) return m, nil case "/keys": @@ -1467,6 +1484,21 @@ func diffPreviewWrite(content string) string { return strings.TrimRight(b.String(), "\n") } +// shellExe returns the path of the user's preferred interactive shell. +// Priority: $SHELL (Unix) / %COMSPEC% (Windows), then platform default. +func shellExe() string { + if sh := os.Getenv("SHELL"); sh != "" { + return sh + } + if sh := os.Getenv("COMSPEC"); sh != "" { + return sh + } + if os.PathSeparator == '\\' { + return "powershell.exe" + } + return "/bin/sh" +} + func detectGitBranch() string { cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") out, err := cmd.Output() diff --git a/internal/tui/completions.go b/internal/tui/completions.go index c74560c..f8ca51a 100644 --- a/internal/tui/completions.go +++ b/internal/tui/completions.go @@ -26,6 +26,7 @@ var builtinCommands = []string{ "/quit", "/replay", "/resume", + "/shell", "/skills", "/usage", }