feat(pty): Phase 2 — interactive shell and bash interactive detection
- /shell [cmd]: launch user's $SHELL via tea.ExecProcess (PTY handoff) hands terminal to the shell and restores TUI on exit. /shell <cmd> runs that command in the shell directly. Detects $SHELL > $COMSPEC > /bin/sh|powershell.exe in order. - bash tool: detect interactive commands before execution Prefix-interactive: sudo, ssh, passwd, vim/vi/nano, less/more, htop/top, mysql/psql, ftp/sftp, git push. Exact-interactive (REPL): python3/python/node/irb/iex/ghci/julia. Returns a tool result with interactive=true metadata and a hint to use /shell instead of hanging or erroring. - completions: add /shell to builtin command list - help: document /shell [cmd]
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 <command> 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",
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-4
@@ -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 /<name> [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 /<name> [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()
|
||||
|
||||
@@ -26,6 +26,7 @@ var builtinCommands = []string{
|
||||
"/quit",
|
||||
"/replay",
|
||||
"/resume",
|
||||
"/shell",
|
||||
"/skills",
|
||||
"/usage",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user