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 paneSize protocol.Size ownerView bool 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.paneSize = msg.Size c.ownerView = msg.DisplayOwner c.renderer = newViewportRenderer(c.renderLayoutLocked(msg.Size)) renderer := c.renderer c.mu.Unlock() c.clearViewport() c.drawChrome() 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.paneSize = msg.Size c.ownerView = msg.DisplayOwner if renderer != nil && (msg.Size.Cols != 0 || msg.Size.Rows != 0) { renderer.SetLayout(c.renderLayoutLocked(msg.Size)) } 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.renderLayoutLocked(c.paneSize)) } 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) renderLayoutLocked(size protocol.Size) terminalLayout { l := c.layout if size.Cols != 0 && size.Cols < l.mainCols { l.mainCols = size.Cols } if size.Rows != 0 && size.Rows < l.mainRows { l.mainRows = size.Rows } return l } 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) } c.mu.Lock() size := c.paneSize ownerView := c.ownerView c.mu.Unlock() if model.FocusedID != "" && !ownerView && size.Cols != 0 && size.Rows != 0 { status = fmt.Sprintf("viewing at owner size %dx%d · %s", size.Cols, size.Rows, 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 }