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:
2026-05-07 15:52:56 +02:00
parent 176926924c
commit 0b1392cf6b
6 changed files with 220 additions and 4 deletions
+9
View File
@@ -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{
+31
View File
@@ -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())
+61
View File
@@ -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",
}
+82
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -26,6 +26,7 @@ var builtinCommands = []string{
"/quit",
"/replay",
"/resume",
"/shell",
"/skills",
"/usage",
}