650 lines
14 KiB
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
|
|
}
|