- Fix CLI adapter files for proper session/workspace handling - Fix session.go and session_test.go - Archive 03+02_cli_agent_cwd task to archive
263 lines
4.9 KiB
Go
263 lines
4.9 KiB
Go
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
)
|
|
|
|
const (
|
|
terminalInputDelay = 2 * time.Millisecond
|
|
)
|
|
|
|
type Options struct {
|
|
Command string
|
|
Args []string
|
|
Env []string
|
|
Rows uint16
|
|
Cols uint16
|
|
Dir string
|
|
}
|
|
|
|
type Output struct {
|
|
Text string
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Tail string
|
|
}
|
|
|
|
type Session interface {
|
|
Output() <-chan Output
|
|
Done() <-chan error
|
|
WritePrompt(ctx context.Context, prompt string) error
|
|
WriteInput(ctx context.Context, data []byte) error
|
|
Resize(rows, cols uint16) error
|
|
Signal(sig os.Signal) error
|
|
Snapshot() Snapshot
|
|
Close() error
|
|
}
|
|
|
|
type sessionImpl struct {
|
|
cmd *exec.Cmd
|
|
input io.WriteCloser
|
|
output io.ReadCloser
|
|
tail *TailBuffer
|
|
outputCh chan Output
|
|
doneCh chan error
|
|
closed bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func StartSession(ctx context.Context, opts Options) (Session, error) {
|
|
cmd := exec.Command(opts.Command, opts.Args...)
|
|
if len(opts.Env) > 0 {
|
|
cmd.Env = append(cmd.Environ(), opts.Env...)
|
|
}
|
|
if opts.Dir != "" {
|
|
cmd.Dir = opts.Dir
|
|
}
|
|
rows := opts.Rows
|
|
if rows == 0 {
|
|
rows = 80
|
|
}
|
|
cols := opts.Cols
|
|
if cols == 0 {
|
|
cols = 240
|
|
}
|
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
|
Rows: rows,
|
|
Cols: cols,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pty start: %w", err)
|
|
}
|
|
|
|
s := &sessionImpl{
|
|
cmd: cmd,
|
|
input: ptmx,
|
|
output: ptmx,
|
|
tail: NewTailBuffer(2048),
|
|
outputCh: make(chan Output, 1024),
|
|
doneCh: make(chan error, 1),
|
|
}
|
|
|
|
go s.readLoop()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *sessionImpl) Output() <-chan Output {
|
|
return s.outputCh
|
|
}
|
|
|
|
func (s *sessionImpl) Done() <-chan error {
|
|
return s.doneCh
|
|
}
|
|
|
|
func (s *sessionImpl) readLoop() {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := s.output.Read(buf)
|
|
if n > 0 {
|
|
text := string(buf[:n])
|
|
s.tail.Append(text)
|
|
s.outputCh <- Output{Text: text}
|
|
}
|
|
if err != nil {
|
|
s.doneCh <- s.cmd.Wait()
|
|
close(s.outputCh)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *sessionImpl) WritePrompt(ctx context.Context, prompt string) error {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return errors.New("terminal session closed")
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
for _, r := range prompt {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return errors.New("terminal session closed")
|
|
}
|
|
_, err := io.WriteString(s.input, string(r))
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
timer := time.NewTimer(terminalInputDelay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("terminal session closed")
|
|
}
|
|
_, err := io.WriteString(s.input, "\r")
|
|
return err
|
|
}
|
|
|
|
func (s *sessionImpl) Snapshot() Snapshot {
|
|
return Snapshot{
|
|
Tail: s.tail.String(),
|
|
}
|
|
}
|
|
|
|
func (s *sessionImpl) Resize(rows, cols uint16) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("terminal session closed")
|
|
}
|
|
if rows == 0 || cols == 0 {
|
|
return errors.New("invalid terminal size: rows and cols must be greater than 0")
|
|
}
|
|
f, ok := s.input.(*os.File)
|
|
if !ok {
|
|
return errors.New("terminal input is not a file")
|
|
}
|
|
return pty.Setsize(f, &pty.Winsize{Rows: rows, Cols: cols})
|
|
}
|
|
|
|
func (s *sessionImpl) WriteInput(ctx context.Context, data []byte) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("terminal session closed")
|
|
}
|
|
_, err := s.input.Write(data)
|
|
return err
|
|
}
|
|
|
|
func (s *sessionImpl) Signal(sig os.Signal) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return errors.New("terminal session closed")
|
|
}
|
|
if s.cmd == nil || s.cmd.Process == nil {
|
|
return errors.New("no process to signal")
|
|
}
|
|
return s.cmd.Process.Signal(sig)
|
|
}
|
|
|
|
func (s *sessionImpl) Close() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.closed {
|
|
return nil
|
|
}
|
|
s.closed = true
|
|
err := s.input.Close()
|
|
if s.cmd != nil && s.cmd.Process != nil {
|
|
_ = s.cmd.Process.Kill()
|
|
}
|
|
if isAlreadyClosedError(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func isAlreadyClosedError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, os.ErrClosed) {
|
|
return true
|
|
}
|
|
errStr := err.Error()
|
|
return strings.Contains(errStr, "file already closed") ||
|
|
strings.Contains(errStr, "use of closed file")
|
|
}
|
|
|
|
// TailBuffer holds a bounded buffer of terminal output.
|
|
type TailBuffer struct {
|
|
mu sync.Mutex
|
|
buf strings.Builder
|
|
max int
|
|
}
|
|
|
|
// NewTailBuffer creates a new TailBuffer with the given maximum capacity in bytes.
|
|
func NewTailBuffer(max int) *TailBuffer {
|
|
return &TailBuffer{max: max}
|
|
}
|
|
|
|
// Append appends a string to the buffer, truncating from the front if it exceeds capacity.
|
|
func (tb *TailBuffer) Append(s string) {
|
|
tb.mu.Lock()
|
|
defer tb.mu.Unlock()
|
|
tb.buf.WriteString(s)
|
|
raw := tb.buf.String()
|
|
if len(raw) <= tb.max {
|
|
return
|
|
}
|
|
tb.buf.Reset()
|
|
tb.buf.WriteString(raw[len(raw)-tb.max:])
|
|
}
|
|
|
|
// String returns the contents of the buffer.
|
|
func (tb *TailBuffer) String() string {
|
|
tb.mu.Lock()
|
|
defer tb.mu.Unlock()
|
|
return tb.buf.String()
|
|
}
|