test terminal typing
This commit is contained in:
+1
-1
@@ -370,7 +370,7 @@ The view shows a breadcrumb from `worktree` down to the current directory or fil
|
|||||||
|
|
||||||
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). 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 `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.
|
||||||
|
|
||||||
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
|
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type terminalTypeArgs struct {
|
|||||||
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. " +
|
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). " +
|
"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). " +
|
||||||
"The human sees everything you type and its output. " +
|
"The human sees everything you type and its output. " +
|
||||||
"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.",
|
||||||
@@ -84,13 +84,22 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
|
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
|
||||||
_, out := term.Subscribe()
|
backlog, out := term.Subscribe()
|
||||||
if out == nil {
|
if out == nil {
|
||||||
exit, _ := term.ExitStatus()
|
exit, _ := term.ExitStatus()
|
||||||
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
|
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
|
||||||
}
|
}
|
||||||
defer term.Unsubscribe(out)
|
defer term.Unsubscribe(out)
|
||||||
|
|
||||||
|
got := []byte{}
|
||||||
|
if promptWait(term) > 0 {
|
||||||
|
var err error
|
||||||
|
got, err = awaitPrompt(ctx, term, out, keepTail(backlog))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stop := atomic.Bool{}
|
stop := atomic.Bool{}
|
||||||
defer stop.Store(true)
|
defer stop.Store(true)
|
||||||
typed := atomic.Int64{}
|
typed := atomic.Int64{}
|
||||||
@@ -108,7 +117,6 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
|||||||
written <- nil
|
written <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
got := []byte{}
|
|
||||||
result := func() string {
|
result := func() string {
|
||||||
res := terminal.Text(got)
|
res := terminal.Text(got)
|
||||||
exit, exited := term.ExitStatus()
|
exit, exited := term.ExitStatus()
|
||||||
@@ -140,11 +148,46 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
|||||||
if !open {
|
if !open {
|
||||||
return result(), nil
|
return result(), nil
|
||||||
}
|
}
|
||||||
got = append(got, b...)
|
got = keepTail(append(got, b...))
|
||||||
if len(got) > 2*typeKeep {
|
|
||||||
got = bytes.Clone(got[len(got)-typeKeep:])
|
|
||||||
}
|
|
||||||
quiet.Reset(typeQuiet)
|
quiet.Reset(typeQuiet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keepTail(b []byte) []byte {
|
||||||
|
if len(b) > 2*typeKeep {
|
||||||
|
return bytes.Clone(b[len(b)-typeKeep:])
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptWait(term *terminal.Terminal) time.Duration {
|
||||||
|
started, last := term.Activity()
|
||||||
|
wait := time.Until(started.Add(typeMaxWait))
|
||||||
|
if !last.IsZero() {
|
||||||
|
wait = min(wait, time.Until(last.Add(typeQuiet)))
|
||||||
|
}
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
|
func awaitPrompt(ctx context.Context, term *terminal.Terminal, out chan []byte, got []byte) ([]byte, error) {
|
||||||
|
for {
|
||||||
|
wait := promptWait(term)
|
||||||
|
if wait <= 0 {
|
||||||
|
return got, nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
case b, open := <-out:
|
||||||
|
timer.Stop()
|
||||||
|
if !open {
|
||||||
|
return got, nil
|
||||||
|
}
|
||||||
|
got = keepTail(append(got, b...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
|
|
||||||
@@ -30,11 +31,13 @@ type Terminal struct {
|
|||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
backlog []byte
|
backlog []byte
|
||||||
clients map[chan []byte]bool
|
clients map[chan []byte]bool
|
||||||
exit string
|
exit string
|
||||||
exited bool
|
exited bool
|
||||||
|
started time.Time
|
||||||
|
lastOutput time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Terminals {
|
func New() *Terminals {
|
||||||
@@ -68,7 +71,7 @@ func (ts *Terminals) Attach(t api.Task, size api.TerminalSize) (*Terminal, error
|
|||||||
return nil, fmt.Errorf("start %s: %w", shell, err)
|
return nil, fmt.Errorf("start %s: %w", shell, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}}
|
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}, started: time.Now()}
|
||||||
ts.byTask[t.Id] = term
|
ts.byTask[t.Id] = term
|
||||||
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
|
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -144,6 +147,7 @@ func (term *Terminal) output(b []byte) {
|
|||||||
term.mu.Lock()
|
term.mu.Lock()
|
||||||
defer term.mu.Unlock()
|
defer term.mu.Unlock()
|
||||||
|
|
||||||
|
term.lastOutput = time.Now()
|
||||||
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:])
|
||||||
@@ -192,6 +196,12 @@ func (term *Terminal) ExitStatus() (string, bool) {
|
|||||||
return term.exit, term.exited
|
return term.exit, term.exited
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (term *Terminal) Activity() (started, lastOutput time.Time) {
|
||||||
|
term.mu.Lock()
|
||||||
|
defer term.mu.Unlock()
|
||||||
|
return term.started, term.lastOutput
|
||||||
|
}
|
||||||
|
|
||||||
func (term *Terminal) Write(b []byte) error {
|
func (term *Terminal) Write(b []byte) error {
|
||||||
_, err := term.ptmx.Write(b)
|
_, err := term.ptmx.Write(b)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user