Add named keys, input-line reporting and append guard to terminal_type

This commit is contained in:
Ian Gulliver
2026-10-20 12:20:11 -07:00
parent cfd06d5582
commit 181482e5d0
3 changed files with 160 additions and 20 deletions
+82 -17
View File
@@ -40,29 +40,62 @@ func (m *Manager) terminalReadTool(l *live) mcp.Tool {
if exited {
out += "\n[shell ended: " + exit + "]"
}
return out, nil
return out + inputLine(term), nil
},
}
}
func inputLine(term *terminal.Terminal) string {
line, ok := term.InputLine()
if !ok {
return ""
}
if line == "" {
return "\n[input line: empty]"
}
return fmt.Sprintf("\n[input line: %q]", line)
}
var terminalKeys = map[string]string{
"enter": "\r",
"escape": "\x1b",
"tab": "\t",
"backspace": "\x7f",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
}
func init() {
for c := 'a'; c <= 'z'; c++ {
terminalKeys["ctrl-"+string(c)] = string(c - 'a' + 1)
}
}
type terminalTypeArgs struct {
Text string `json:"text"`
Keys []string `json:"keys"`
Text string `json:"text"`
Append bool `json:"append"`
}
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_type",
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running and waiting for its prompt before typing. " +
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
"keys are named keys sent first (ctrl-c cancels, ctrl-u clears the input line, enter runs it), then text goes to the shell exactly as keystrokes, so end a command with a real line break to run it (an escape you write in text, such as \"\\u0003\", can arrive as those literal characters, so send control keys through keys), or press enter in a later call. " +
"When the input line still holds unsent text after keys, text is refused, so that it isn't joined onto someone else's command: clear it with ctrl-u first, or set append to add to it on purpose. " +
"Replies from this tool and terminal_read end with the shell's current input line when zsh is at its prompt. " +
"The human sees everything you type and its output. " +
"To hand the human a command to run (one needing their password, say), type it without the newline and tell them to press Enter in the Terminal tab. In a zsh terminal, the reply then says byre will message you when the shell next returns to its prompt, with the exit status and output, starting a turn if you are idle, so end your turn rather than asking whether they ran it. " +
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"text": {"type": "string", "description": "The keystrokes to send."}
},
"required": ["text"]
"keys": {"type": "array", "items": {"type": "string", "enum": ["ctrl-a", "ctrl-b", "ctrl-c", "ctrl-d", "ctrl-e", "ctrl-f", "ctrl-g", "ctrl-h", "ctrl-i", "ctrl-j", "ctrl-k", "ctrl-l", "ctrl-m", "ctrl-n", "ctrl-o", "ctrl-p", "ctrl-q", "ctrl-r", "ctrl-s", "ctrl-t", "ctrl-u", "ctrl-v", "ctrl-w", "ctrl-x", "ctrl-y", "ctrl-z", "enter", "escape", "tab", "backspace", "up", "down", "left", "right"]}, "description": "Named keys to press, in order, before text."},
"text": {"type": "string", "description": "The keystrokes to send after keys."},
"append": {"type": "boolean", "description": "Type text even though the input line already holds unsent text, adding to it."}
}
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := terminalTypeArgs{}
@@ -81,15 +114,51 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
if err != nil {
return "", err
}
if args.Text == "" || strings.HasSuffix(args.Text, "\n") || strings.HasSuffix(args.Text, "\r") {
return typeAndWait(ctx, term, args.Text, nil)
keys := ""
for _, k := range args.Keys {
b, ok := terminalKeys[k]
if !ok {
return "", fmt.Errorf("unknown key %q", k)
}
keys += b
}
term.CancelWake()
startLine, _ := term.InputLine()
sent := keys
out := ""
if keys != "" || args.Text == "" {
out, err = typeAndWait(ctx, term, keys)
if err != nil {
return "", err
}
}
if args.Text != "" {
if line, _ := term.InputLine(); line != "" && !args.Append {
out += fmt.Sprintf("\nThe terminal's input line already has unsent text: %q. Your text was not typed. Send keys [\"ctrl-u\"] to clear it (keys [\"ctrl-c\"] when it continues an unfinished command from earlier lines), or set append to add to it.", line)
} else {
more, err := typeAndWait(ctx, term, args.Text)
if err != nil {
return "", err
}
out += more
sent += args.Text
}
}
out += inputLine(term)
submitted := strings.HasSuffix(sent, "\n") || strings.HasSuffix(sent, "\r")
if !term.Prompts {
out, err := typeAndWait(ctx, term, args.Text, nil)
return out + "\n[this shell isn't zsh, so byre can't tell when the line is run: ask the human to tell you once they have run it]", err
if args.Text != "" {
out += "\n[this shell isn't zsh, so byre can't see its input line: your text may have joined unsent text already there]"
}
if args.Text != "" && !submitted {
out += "\n[this shell isn't zsh, so byre can't tell when the line is run: ask the human to tell you once they have run it]"
}
return out, nil
}
out, err := typeAndWait(ctx, term, args.Text, m.wakeOnPrompt(l))
return out + "\n[byre messages you when the shell is next back at its prompt]", err
if term.WakeOnPrompt(m.wakeOnPrompt(l), !submitted && (startLine != "" || args.Text != "")) {
out += "\n[byre messages you when the shell is next back at its prompt]"
}
return out, nil
},
}
}
@@ -106,8 +175,7 @@ func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) {
}
}
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string, wake func(string, []byte)) (string, error) {
term.CancelWake()
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
@@ -138,9 +206,6 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string, wake
}
typed.Add(1)
}
if wake != nil && typed.Load() == int64(len(text)) {
term.WakeOnPrompt(wake)
}
written <- nil
}()
+76 -1
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -22,8 +23,12 @@ const (
clientBuf = 256
MaxSize = 1 << 15
promptMark = "\x1b]133;D;"
lineMark = "\x1b]7701;"
carryMax = 64 << 10
)
var lineMarks = [][]byte{[]byte(promptMark), []byte(lineMark)}
const zshenv = `if [[ -n $BYRE_ZDOTDIR ]]; then
ZDOTDIR=$BYRE_ZDOTDIR
else
@@ -35,6 +40,13 @@ if [[ -o interactive ]]; then
unsetopt PROMPT_SP
_byre_precmd() { print -n "\e]133;D;$?\a" }
precmd_functions+=(_byre_precmd)
_byre_line() { local line=$PREBUFFER$BUFFER; print -rn -- $'\e]7701;L'"${line//[[:cntrl:]]/ }"$'\a' }
_byre_line_end() { print -rn -- $'\e]7701;E\a' }
zmodload zsh/zle
autoload -Uz add-zle-hook-widget
add-zle-hook-widget line-init _byre_line
add-zle-hook-widget line-pre-redraw _byre_line
add-zle-hook-widget line-finish _byre_line_end
fi
`
@@ -58,6 +70,9 @@ type Terminal struct {
started time.Time
lastOutput time.Time
wake *wake
carry []byte
line string
atPrompt bool
}
type wake struct {
@@ -188,6 +203,9 @@ func (term *Terminal) output(b []byte) {
if term.wake != nil {
term.checkWake(b)
}
if term.Prompts {
term.trackLine(b)
}
term.backlog = append(term.backlog, b...)
if len(term.backlog) > 2*backlogSize {
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
@@ -222,10 +240,67 @@ func (term *Terminal) checkWake(b []byte) {
go w.fn(string(w.out[start+len(promptMark):start+end]), w.out[:start])
}
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte)) {
func (term *Terminal) trackLine(b []byte) {
s := append(term.carry, b...)
term.carry = nil
for {
i := bytes.Index(s, []byte("\x1b]"))
if i < 0 {
if len(s) > 0 && s[len(s)-1] == 0x1b {
term.carry = []byte{0x1b}
}
return
}
rest := s[i:]
var prefix []byte
for _, m := range lineMarks {
if bytes.HasPrefix(rest, m) {
prefix = m
break
}
if len(rest) < len(m) && bytes.HasPrefix(m, rest) {
term.carry = bytes.Clone(rest)
return
}
}
if prefix == nil {
s = rest[2:]
continue
}
end := bytes.IndexByte(rest, '\a')
if end < 0 {
if len(rest) < carryMax {
term.carry = bytes.Clone(rest)
}
return
}
body := string(rest[len(prefix):end])
switch {
case string(prefix) == promptMark:
term.atPrompt, term.line = true, ""
case body == "E":
term.atPrompt, term.line = false, ""
case strings.HasPrefix(body, "L"):
term.atPrompt, term.line = true, body[1:]
}
s = rest[end+1:]
}
}
func (term *Terminal) InputLine() (string, bool) {
term.mu.Lock()
defer term.mu.Unlock()
return term.line, term.atPrompt
}
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte), mayRun bool) bool {
term.mu.Lock()
defer term.mu.Unlock()
if term.atPrompt && term.line == "" || !term.atPrompt && !mayRun {
return false
}
term.wake = &wake{fn: fn}
return true
}
func (term *Terminal) CancelWake() {