Add named keys, input-line reporting and append guard to terminal_type
This commit is contained in:
+82
-17
@@ -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
|
||||
}()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user