Add named keys, input-line reporting and append guard to terminal_type
This commit is contained in:
+2
-2
@@ -423,9 +423,9 @@ Markdown rendered with a task (its transcript, questions, reviews, Markdown file
|
|||||||
|
|
||||||
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/terminal`, which the server creates and hands `agents.New`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`, `internal/server/terminal.go`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
|
Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's environment) on a PTY (`github.com/creack/pty`), started in the task's worktree with byre's environment plus `TERM=xterm-256color`, `COLORTERM=truecolor` and the task's `PORT` (`internal/terminal`, which the server creates and hands `agents.New`). `GET /api/tasks/{taskId}/terminal?cols=&rows=` is a WebSocket (`github.com/coder/websocket`, `internal/server/terminal.go`), the only WebSocket in byre: it attaches to the task's terminal, starting the shell at that size if none is running and resizing it to that size if one is, and works only while the task is active (409 otherwise). The server first sends the last `terminalBacklog` bytes the terminal printed, then its output as it comes, as binary messages; the client sends keystrokes as binary messages and a `TerminalSize` JSON text message on every resize. Any number of sockets can attach to one terminal: they all get its output and all type into it, and the last resize wins. A socket that falls `terminalClientBuf` reads behind is closed and the app reconnects. When the shell exits, every socket is closed with code 4000 and the exit status (`exit status 0`, `signal: hangup`) as the reason, and the next attach starts a new shell. Merging or discarding the task, and stopping byre, sends the shell's process group SIGHUP and closes the PTY, so terminals don't survive a restart. The WebSocket handshake checks that `Origin` matches `Host`, so another site can't open one through the user's Tailscale identity. The log records `terminal started` and `terminal ended` with the task, pid and exit status.
|
||||||
|
|
||||||
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `keys`, a list of named keys (`terminalKeys`: `ctrl-a` to `ctrl-z`, `enter`, `escape`, `tab`, `backspace` and the arrows), and then `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Named keys exist because Claude Code passes tool arguments through as the text the model wrote, so an escape like `\u0003` in `text` reaches the shell as six characters. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
||||||
|
|
||||||
A `terminal_type` whose text doesn't end in a newline (`\n` or `\r`) leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: `terminal.New` writes a `.zshenv` (the `zshenv` constant in `internal/terminal/terminal.go`) into `<data>/zsh`, and the shell starts with `ZDOTDIR` pointing there and the user's own `ZDOTDIR` (empty when unset) in `BYRE_ZDOTDIR`. That `.zshenv` puts `ZDOTDIR` back (unsetting it when it was empty), sources the user's `.zshenv`, and in an interactive shell turns off `PROMPT_SP` and appends a `precmd` hook that prints `ESC ] 133 ; D ; <exit status> BEL` (the OSC 133 "command finished" mark) before every prompt; the rest of the user's startup files then load from their own `ZDOTDIR` as usual. `PROMPT_SP` would print a reverse-video `%` padded to the full width the shell had at the time, so replaying an agent-opened shell's output (started at `agentTerminalSize`, 120 columns) in a narrower Terminal tab wraps each one onto an extra line; without it, a command whose output lacks a trailing newline has its last line drawn over by the prompt. Terminals ignore OSC sequences they don't know, and `terminal.Text` drops them, so nothing shows. Other shells get no integration (`Terminal.Prompts` is false), and nothing wakes their agents. The reply to such a `terminal_type` ends with a line saying which: byre will message the agent, or, in another shell, the agent must ask the human to say when they have run it. Once all of the text is typed, `Terminal.WakeOnPrompt` arms a one-shot wake that collects the output from then on and fires at the first mark; every `terminal_type` first cancels any armed wake (`CancelWake`), so the agent pressing Enter itself, or typing more, doesn't wake it. A zsh line editor runs `precmd` after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a `byre` message (`Manager.wakeOnPrompt`, through `Manager.Message`, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records `woke agent on terminal prompt` with the session, task and exit status.
|
A `terminal_type` that leaves text on the input line leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: `terminal.New` writes a `.zshenv` (the `zshenv` constant in `internal/terminal/terminal.go`) into `<data>/zsh`, and the shell starts with `ZDOTDIR` pointing there and the user's own `ZDOTDIR` (empty when unset) in `BYRE_ZDOTDIR`. That `.zshenv` puts `ZDOTDIR` back (unsetting it when it was empty), sources the user's `.zshenv`, and in an interactive shell turns off `PROMPT_SP` and appends a `precmd` hook that prints `ESC ] 133 ; D ; <exit status> BEL` (the OSC 133 "command finished" mark) before every prompt. It also loads `zsh/zle` and adds `add-zle-hook-widget` hooks that report the line being edited: when the editor starts a line (`line-init`) and on every redraw (`line-pre-redraw`) it prints `ESC ] 7701 ; L <$PREBUFFER$BUFFER, control characters as spaces> BEL`, so the earlier lines of an unfinished command (an open quote, a trailing `\`) count as part of the line, and when the line is sent (`line-finish`) `ESC ] 7701 ; E BEL`. `Terminal.trackLine` reads both marks and the 133 mark from the output (carrying a mark split across reads) into `Terminal.InputLine`: at the prompt with the line after a 133 or `L` mark, not at the prompt after `E`. Both tools end their reply with `[input line: "…"]` or `[input line: empty]` while zsh is at its prompt. `terminal_type` presses `keys` and waits for the output as for text, then refuses `text`, typing none of it, when the input line still holds text, so its text isn't joined onto a command the human or an earlier call left there; `append` types it anyway. In another shell nothing is checked, and a reply to typed text says the text may have joined unsent text. A user `.zshrc` that sets `zle-line-pre-redraw` with `zle -N` rather than `add-zle-hook-widget` replaces the hook, and the line then stays empty. The rest of the user's startup files then load from their own `ZDOTDIR` as usual. `PROMPT_SP` would print a reverse-video `%` padded to the full width the shell had at the time, so replaying an agent-opened shell's output (started at `agentTerminalSize`, 120 columns) in a narrower Terminal tab wraps each one onto an extra line; without it, a command whose output lacks a trailing newline has its last line drawn over by the prompt. Terminals ignore OSC sequences they don't know, and `terminal.Text` drops them, so nothing shows. Other shells get no integration (`Terminal.Prompts` is false), and nothing wakes their agents. The reply to such a `terminal_type` ends with a line saying which: byre will message the agent, or, in another shell (where text not ending in a newline counts as a line left), the agent must ask the human to say when they have run it. Every `terminal_type` first cancels any armed wake (`CancelWake`); once it has typed and waited, `Terminal.WakeOnPrompt` arms a one-shot wake that collects the output from then on and fires at the first mark. It decides under the terminal's lock, so a prompt can't slip between the check and the arming: it arms when zsh is at its prompt with text on the input line, or when a command is running that the call didn't submit itself (its keys and typed text don't end in a newline) while it typed text or found a line pending, which is the human pressing Enter during the call's wait. So the agent pressing Enter itself or clearing the line doesn't wake it, editing the line with keys or a refused call keeps the wake, and a line the human runs before the reply still reports its end. A zsh line editor runs `precmd` after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a `byre` message (`Manager.wakeOnPrompt`, through `Manager.Message`, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records `woke agent on terminal prompt` with the session, task and exit status.
|
||||||
|
|
||||||
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
|
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
|
||||||
|
|
||||||
|
|||||||
+81
-16
@@ -40,29 +40,62 @@ func (m *Manager) terminalReadTool(l *live) mcp.Tool {
|
|||||||
if exited {
|
if exited {
|
||||||
out += "\n[shell ended: " + exit + "]"
|
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 {
|
type terminalTypeArgs struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
Append bool `json:"append"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||||
return mcp.Tool{
|
return mcp.Tool{
|
||||||
Name: "terminal_type",
|
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. " +
|
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. " +
|
"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. " +
|
"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.",
|
"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(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"text": {"type": "string", "description": "The keystrokes to send."}
|
"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."},
|
||||||
"required": ["text"]
|
"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) {
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||||
args := terminalTypeArgs{}
|
args := terminalTypeArgs{}
|
||||||
@@ -81,15 +114,51 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if args.Text == "" || strings.HasSuffix(args.Text, "\n") || strings.HasSuffix(args.Text, "\r") {
|
keys := ""
|
||||||
return typeAndWait(ctx, term, args.Text, nil)
|
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 {
|
if !term.Prompts {
|
||||||
out, err := typeAndWait(ctx, term, args.Text, nil)
|
if args.Text != "" {
|
||||||
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
|
out += "\n[this shell isn't zsh, so byre can't see its input line: your text may have joined unsent text already there]"
|
||||||
}
|
}
|
||||||
out, err := typeAndWait(ctx, term, args.Text, m.wakeOnPrompt(l))
|
if args.Text != "" && !submitted {
|
||||||
return out + "\n[byre messages you when the shell is next back at its prompt]", err
|
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
|
||||||
|
}
|
||||||
|
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) {
|
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
|
||||||
term.CancelWake()
|
|
||||||
backlog, out := term.Subscribe()
|
backlog, out := term.Subscribe()
|
||||||
if out == nil {
|
if out == nil {
|
||||||
exit, _ := term.ExitStatus()
|
exit, _ := term.ExitStatus()
|
||||||
@@ -138,9 +206,6 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string, wake
|
|||||||
}
|
}
|
||||||
typed.Add(1)
|
typed.Add(1)
|
||||||
}
|
}
|
||||||
if wake != nil && typed.Load() == int64(len(text)) {
|
|
||||||
term.WakeOnPrompt(wake)
|
|
||||||
}
|
|
||||||
written <- nil
|
written <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,8 +23,12 @@ const (
|
|||||||
clientBuf = 256
|
clientBuf = 256
|
||||||
MaxSize = 1 << 15
|
MaxSize = 1 << 15
|
||||||
promptMark = "\x1b]133;D;"
|
promptMark = "\x1b]133;D;"
|
||||||
|
lineMark = "\x1b]7701;"
|
||||||
|
carryMax = 64 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var lineMarks = [][]byte{[]byte(promptMark), []byte(lineMark)}
|
||||||
|
|
||||||
const zshenv = `if [[ -n $BYRE_ZDOTDIR ]]; then
|
const zshenv = `if [[ -n $BYRE_ZDOTDIR ]]; then
|
||||||
ZDOTDIR=$BYRE_ZDOTDIR
|
ZDOTDIR=$BYRE_ZDOTDIR
|
||||||
else
|
else
|
||||||
@@ -35,6 +40,13 @@ if [[ -o interactive ]]; then
|
|||||||
unsetopt PROMPT_SP
|
unsetopt PROMPT_SP
|
||||||
_byre_precmd() { print -n "\e]133;D;$?\a" }
|
_byre_precmd() { print -n "\e]133;D;$?\a" }
|
||||||
precmd_functions+=(_byre_precmd)
|
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
|
fi
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -58,6 +70,9 @@ type Terminal struct {
|
|||||||
started time.Time
|
started time.Time
|
||||||
lastOutput time.Time
|
lastOutput time.Time
|
||||||
wake *wake
|
wake *wake
|
||||||
|
carry []byte
|
||||||
|
line string
|
||||||
|
atPrompt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type wake struct {
|
type wake struct {
|
||||||
@@ -188,6 +203,9 @@ func (term *Terminal) output(b []byte) {
|
|||||||
if term.wake != nil {
|
if term.wake != nil {
|
||||||
term.checkWake(b)
|
term.checkWake(b)
|
||||||
}
|
}
|
||||||
|
if term.Prompts {
|
||||||
|
term.trackLine(b)
|
||||||
|
}
|
||||||
term.backlog = append(term.backlog, b...)
|
term.backlog = append(term.backlog, b...)
|
||||||
if len(term.backlog) > 2*backlogSize {
|
if len(term.backlog) > 2*backlogSize {
|
||||||
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-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])
|
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()
|
term.mu.Lock()
|
||||||
defer term.mu.Unlock()
|
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}
|
term.wake = &wake{fn: fn}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (term *Terminal) CancelWake() {
|
func (term *Terminal) CancelWake() {
|
||||||
|
|||||||
Reference in New Issue
Block a user