Files
patterm/internal/app/client_net.go

650 lines
14 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"syscall"
"time"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/hjbdev/patterm/internal/protocol"
)
const (
clientKeyCtrlK byte = 0x0b
clientKeyCtrlBracket byte = 0x1d
)
type ClientOptions struct {
ProjectDir string
Transport protocol.Transport
Stdin io.Reader
Stdout io.Writer
RawMode bool
AutoStart bool
Token string
Cols uint16
Rows uint16
}
func RunAttachedClient(ctx context.Context, opts ClientOptions) error {
if opts.ProjectDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
opts.ProjectDir = cwd
}
if opts.Stdin == nil {
opts.Stdin = os.Stdin
}
if opts.Stdout == nil {
opts.Stdout = os.Stdout
}
if opts.Transport == nil {
t, err := dialDaemonTransport(opts.ProjectDir, opts.AutoStart)
if err != nil {
return err
}
opts.Transport = t
defer t.Close()
}
if opts.Cols == 0 || opts.Rows == 0 {
opts.Cols, opts.Rows = clientHostSize(opts.Stdin)
}
c := newNetClient(opts)
return c.run(ctx)
}
func DialTCPTransport(addr string) (protocol.Transport, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
return protocol.NewConnTransport(conn), nil
}
func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
socket, _, err := RuntimeDaemonPaths()
if err != nil {
return nil, err
}
conn, err := net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
if !autoStart {
return nil, err
}
if err := startDaemonProcess(projectDir); err != nil {
return nil, err
}
deadline := time.Now().Add(5 * time.Second)
var last error
for time.Now().Before(deadline) {
conn, err = net.Dial("unix", socket)
if err == nil {
return protocol.NewConnTransport(conn), nil
}
last = err
time.Sleep(50 * time.Millisecond)
}
return nil, fmt.Errorf("daemon did not become ready: %w", last)
}
func startDaemonProcess(projectDir string) error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe, "daemon", "--project", projectDir)
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err == nil {
defer devNull.Close()
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
}
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
type netClient struct {
t protocol.Transport
in io.Reader
out io.Writer
raw bool
projectDir string
token string
layout terminalLayout
mu sync.Mutex
focusedID string
chrome chromeModel
renderer *viewportRenderer
palette *clientCommandPrompt
}
type clientCommandPrompt struct {
buf []byte
}
func newNetClient(opts ClientOptions) *netClient {
layout := newTerminalLayout(opts.Cols, opts.Rows)
return &netClient{
t: opts.Transport,
in: opts.Stdin,
out: opts.Stdout,
raw: opts.RawMode,
projectDir: opts.ProjectDir,
token: opts.Token,
layout: layout,
renderer: newViewportRenderer(layout),
}
}
func (c *netClient) run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var restore *term.State
if c.raw {
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
st, err := term.MakeRaw(int(f.Fd()))
if err != nil {
return err
}
restore = st
defer term.Restore(int(f.Fd()), restore)
}
}
c.enterScreen()
defer c.leaveScreen()
if err := c.sendAttach(); err != nil {
return err
}
errCh := make(chan error, 2)
go func() { errCh <- c.recvLoop(ctx, cancel) }()
go func() { errCh <- c.stdinLoop(ctx, cancel) }()
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
defer signal.Stop(winch)
go func() {
for {
select {
case <-ctx.Done():
return
case <-winch:
cols, rows := clientHostSize(c.in)
_ = c.resize(cols, rows)
c.enterScreen()
c.drawChrome()
}
}
}()
}
select {
case <-ctx.Done():
_ = c.t.Close()
return nil
case err := <-errCh:
cancel()
_ = c.t.Close()
if errors.Is(err, io.EOF) || errors.Is(err, protocol.ErrTransportClosed) {
return nil
}
return err
}
}
func (c *netClient) sendAttach() error {
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
ProjectPath: c.projectPath(),
Token: c.token,
TermSize: protocol.Size{
Cols: c.layout.childCols(),
Rows: c.layout.childRows(),
},
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) projectPath() string {
return c.projectDir
}
func (c *netClient) recvLoop(ctx context.Context, cancel func()) error {
for {
select {
case <-ctx.Done():
return nil
default:
}
f, err := c.t.Recv()
if err != nil {
return err
}
if err := c.handleFrame(f); err != nil {
return err
}
if f.Type == protocol.FrameDetach {
cancel()
return nil
}
}
}
func (c *netClient) handleFrame(f protocol.Frame) error {
switch f.Type {
case protocol.FrameError:
msg, _ := protocol.Decode[protocol.Error](f)
if msg.Message == "" {
msg.Message = "daemon error"
}
return fmt.Errorf("%s", msg.Message)
case protocol.FrameHello:
return nil
case protocol.FrameProjectList:
return nil
case protocol.FrameChrome:
msg, err := protocol.Decode[protocol.Chrome](f)
if err != nil {
return err
}
var model chromeModel
if err := json.Unmarshal(msg.Model, &model); err != nil {
return err
}
c.mu.Lock()
c.chrome = model
if model.FocusedID != "" {
c.focusedID = model.FocusedID
}
c.mu.Unlock()
c.drawChrome()
case protocol.FramePaneSnapshot:
msg, err := protocol.Decode[protocol.PaneSnapshot](f)
if err != nil {
return err
}
c.mu.Lock()
c.focusedID = msg.PaneID
c.renderer = newViewportRenderer(c.layout)
renderer := c.renderer
c.mu.Unlock()
c.clearViewport()
c.writeWrapped(renderer.Render(msg.Bytes))
case protocol.FramePaneChunk:
msg, err := protocol.Decode[protocol.PaneChunk](f)
if err != nil {
return err
}
c.mu.Lock()
focused := c.focusedID
renderer := c.renderer
c.mu.Unlock()
if msg.PaneID == focused && renderer != nil {
c.writeWrapped(renderer.Render(msg.Bytes))
}
case protocol.FrameLifecycle:
// The daemon follows lifecycle changes with chrome/snapshot updates
// when focus changes. Keep this as a wake point for future richer
// client-side state without blocking the frame stream.
return nil
}
return nil
}
func (c *netClient) stdinLoop(ctx context.Context, cancel func()) error {
buf := make([]byte, 4096)
for {
n, err := c.in.Read(buf)
if n > 0 {
if done, perr := c.processInput(buf[:n]); perr != nil || done {
cancel()
return perr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
select {
case <-ctx.Done():
return nil
default:
}
}
}
func (c *netClient) processInput(chunk []byte) (bool, error) {
c.mu.Lock()
if c.palette != nil {
p := c.palette
c.mu.Unlock()
return c.processPaletteInput(p, chunk)
}
c.mu.Unlock()
forward := make([]byte, 0, len(chunk))
flush := func() error {
if len(forward) == 0 {
return nil
}
c.mu.Lock()
paneID := c.focusedID
c.mu.Unlock()
if paneID != "" {
f, err := protocol.NewFrame(protocol.FrameInput, protocol.Input{PaneID: paneID, Bytes: append([]byte(nil), forward...)})
if err != nil {
return err
}
if err := c.t.Send(f); err != nil {
return err
}
}
forward = forward[:0]
return nil
}
for _, b := range chunk {
switch b {
case clientKeyCtrlBracket:
if err := flush(); err != nil {
return false, err
}
return true, c.sendDetach()
case clientKeyCtrlK:
if err := flush(); err != nil {
return false, err
}
c.mu.Lock()
c.palette = &clientCommandPrompt{}
c.mu.Unlock()
c.drawPrompt()
case 0x17: // Ctrl-W: previous focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(-1)
case 0x13: // Ctrl-S: next focus
if err := flush(); err != nil {
return false, err
}
_ = c.focusRelative(1)
default:
forward = append(forward, b)
}
}
return false, flush()
}
func (c *netClient) processPaletteInput(p *clientCommandPrompt, chunk []byte) (bool, error) {
for _, b := range chunk {
switch b {
case 0x1b: // ESC
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
c.drawChrome()
return false, nil
case 'd':
if len(p.buf) == 0 {
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
return true, c.sendDetach()
}
p.buf = append(p.buf, b)
case '\r', '\n':
command := strings.TrimSpace(string(p.buf))
c.mu.Lock()
c.palette = nil
c.mu.Unlock()
if command == "" {
c.drawChrome()
return false, nil
}
return false, c.sendSpawnCommand(command)
case 0x7f, 0x08:
if len(p.buf) > 0 {
p.buf = p.buf[:len(p.buf)-1]
}
c.drawPrompt()
default:
if b >= 0x20 {
p.buf = append(p.buf, b)
c.drawPrompt()
}
}
}
return false, nil
}
func (c *netClient) sendDetach() error {
f, err := protocol.NewFrame(protocol.FrameDetach, protocol.Detach{})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) sendSpawnCommand(command string) error {
data, err := json.Marshal(map[string]any{
"argv": []string{command},
"name": command,
"shell": true,
})
if err != nil {
return err
}
f, err := protocol.NewFrame(protocol.FramePaletteCommand, protocol.PaletteCommand{
Kind: "spawn_command",
Data: data,
})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) focusRelative(delta int) error {
c.mu.Lock()
model := c.chrome
current := c.focusedID
c.mu.Unlock()
ids := make([]string, 0, len(model.Processes)+len(model.AgentTree)+len(model.Tabs))
for _, n := range model.Sidebar {
if n.ChildID != "" {
ids = append(ids, n.ChildID)
}
}
if len(ids) == 0 {
for _, p := range model.Processes {
ids = append(ids, p.ID)
}
for _, p := range model.Tabs {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
idx := 0
for i, id := range ids {
if id == current {
idx = i
break
}
}
idx = (idx + delta + len(ids)) % len(ids)
f, err := protocol.NewFrame(protocol.FrameFocus, protocol.Focus{PaneID: ids[idx]})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) resize(cols, rows uint16) error {
c.mu.Lock()
c.layout = newTerminalLayout(cols, rows)
if c.renderer != nil {
c.renderer.SetLayout(c.layout)
}
size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()}
c.mu.Unlock()
f, err := protocol.NewFrame(protocol.FrameResize, protocol.Resize{Size: size})
if err != nil {
return err
}
return c.t.Send(f)
}
func (c *netClient) enterScreen() {
_, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
c.installScrollRegion()
}
func (c *netClient) leaveScreen() {
_, _ = c.out.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
}
func (c *netClient) installScrollRegion() {
mainBottom := int(c.layout.statusRow) - statusRows
if mainBottom < int(c.layout.mainTop) {
return
}
fmt.Fprintf(c.out, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
int(c.layout.mainTop), mainBottom,
int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) clearViewport() {
for row := int(c.layout.mainTop); row < int(c.layout.statusRow); row++ {
fmt.Fprintf(c.out, "\x1b[%d;%dH\x1b[%dX", row, int(c.layout.mainLeft), int(c.layout.childCols()))
}
fmt.Fprintf(c.out, "\x1b[%d;%dH", int(c.layout.mainTop), int(c.layout.mainLeft))
}
func (c *netClient) writeWrapped(out []byte) {
if len(out) == 0 {
return
}
wrapped := make([]byte, 0, len(out)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
_, _ = c.out.Write(wrapped)
}
func (c *netClient) drawChrome() {
c.mu.Lock()
model := c.chrome
prompt := c.palette
c.mu.Unlock()
var b strings.Builder
width := int(c.layout.childCols())
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX\x1b[2;1H\x1b[%dX\x1b[3;1H\x1b[%dX", width, width, width)
if len(model.Tabs) == 0 {
fmt.Fprintf(&b, "\x1b[1;2H%s+ new%s", styleDim, styleReset)
} else {
col := 1
for _, tab := range model.Tabs {
label := fitName(tab.Name, 18)
style := styleHint
if tab.ID == model.ActiveAgentID || tab.ID == model.FocusedID {
style = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH%s %s %s", col, style, label, styleReset)
col += visibleLen(label) + 3
if col >= width {
break
}
}
}
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s", styleBorder, strings.Repeat("─", width), styleReset)
if c.layout.sidebarVisible {
c.appendSidebar(&b, model)
}
status := "Ctrl-K command palette · Ctrl-] detach"
if model.FocusedID != "" {
status = fmt.Sprintf("%s · %s", model.FocusedID, status)
}
if prompt != nil {
status = "command: " + string(prompt.buf)
}
fmt.Fprintf(&b, "\x1b[%d;1H\x1b[7m%s%s", int(c.layout.statusRow), fitName(status, int(c.layout.hostCols)), styleReset)
_, _ = c.out.Write([]byte(b.String()))
}
func (c *netClient) appendSidebar(b *strings.Builder, model chromeModel) {
border := int(c.layout.sidebarLeft) - 1
for row := 1; row <= int(c.layout.statusRow)-1; row++ {
fmt.Fprintf(b, "\x1b[%d;%dH%s│%s", row, border, styleBorder, styleReset)
}
col := int(c.layout.sidebarLeft)
row := 1
write := func(text string) {
if row >= int(c.layout.statusRow) {
return
}
fmt.Fprintf(b, "\x1b[%d;%dH%-*s", row, col, int(c.layout.sidebarWidth)-1, fitName(text, int(c.layout.sidebarWidth)-1))
row++
}
write(styleActive + "Processes" + styleReset)
for _, p := range model.Processes {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Agent Tree" + styleReset)
for _, p := range model.AgentTree {
prefix := " "
if p.ID == model.FocusedID {
prefix = "▎ "
}
write(prefix + p.Name)
}
row++
write(styleActive + "Scratchpads" + styleReset)
for _, p := range model.Scratchpads {
write(" " + p.Name)
}
}
func (c *netClient) drawPrompt() {
c.drawChrome()
}
func clientHostSize(r io.Reader) (cols, rows uint16) {
if f, ok := r.(*os.File); ok {
ws, err := cpty.GetsizeFull(f)
if err == nil && ws.Cols > 0 && ws.Rows > 0 {
return ws.Cols, ws.Rows
}
}
return 120, 40
}