attach default client to local daemon
This commit is contained in:
@@ -13,6 +13,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
project listing, focused-pane snapshots, pane chunks, resize/focus
|
||||
updates, and daemon-owned command spawn requests while keeping child
|
||||
processes alive after a client disconnects.
|
||||
- The default `patterm [dir]` startup now auto-starts the local daemon
|
||||
on demand and attaches a thin terminal client over the unix-socket
|
||||
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
|
||||
single-process path available as an escape hatch.
|
||||
- patterm can now keep multiple local projects loaded in one loopback
|
||||
daemon core, with command-palette entries to switch the current
|
||||
client view or open another project without tearing down processes
|
||||
|
||||
@@ -64,6 +64,7 @@ func main() {
|
||||
var (
|
||||
projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||
showVersion = flag.Bool("version", false, "print version and exit")
|
||||
inProcess = flag.Bool("in-process", false, "run the legacy single-process TUI instead of attaching to the daemon")
|
||||
debugDir = flag.String("debug", "", "write debug logs + per-child raw PTY output to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/debug when DIR is omitted)")
|
||||
profileDir = flag.String("profile", "", "write pprof files (cpu/heap/goroutine) and live perf counters (metrics.jsonl per-second, metrics.json + summary.txt on exit) to DIR (auto-picks a dated subdir under $XDG_STATE_HOME/patterm/profile when DIR is omitted)")
|
||||
)
|
||||
@@ -84,6 +85,8 @@ func main() {
|
||||
}
|
||||
if *projectDir != "" {
|
||||
cwd = *projectDir
|
||||
} else if flag.NArg() > 0 {
|
||||
cwd = flag.Arg(0)
|
||||
}
|
||||
key, err := projectkey.Key(cwd)
|
||||
if err != nil {
|
||||
@@ -107,6 +110,7 @@ func main() {
|
||||
defer stopProfile()
|
||||
|
||||
ctx := context.Background()
|
||||
if *inProcess || os.Getenv("PATTERM_NO_DAEMON") != "" {
|
||||
if err := app.Run(ctx, app.Options{
|
||||
ProjectDir: cwd,
|
||||
ProjectKey: key,
|
||||
@@ -115,6 +119,20 @@ func main() {
|
||||
}); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if resolvedDebug != "" || resolvedProfile != "" {
|
||||
die("--debug and --profile currently require --in-process")
|
||||
}
|
||||
if err := app.RunAttachedClient(ctx, app.ClientOptions{
|
||||
ProjectDir: cwd,
|
||||
Stdin: os.Stdin,
|
||||
Stdout: os.Stdout,
|
||||
RawMode: true,
|
||||
AutoStart: true,
|
||||
}); err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDiagDir turns the raw flag value into an absolute directory
|
||||
@@ -223,6 +241,8 @@ func runDaemonCommand() {
|
||||
}
|
||||
if *projectDir != "" {
|
||||
cwd = *projectDir
|
||||
} else if flag.NArg() > 0 {
|
||||
cwd = flag.Arg(0)
|
||||
}
|
||||
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
|
||||
die("daemon: %v", err)
|
||||
|
||||
637
internal/app/client_net.go
Normal file
637
internal/app/client_net.go
Normal file
@@ -0,0 +1,637 @@
|
||||
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
|
||||
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 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
|
||||
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,
|
||||
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(),
|
||||
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
|
||||
}
|
||||
157
internal/app/client_net_test.go
Normal file
157
internal/app/client_net_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/protocol"
|
||||
)
|
||||
|
||||
func TestNetClientFrameLoopSendsFocusedInput(t *testing.T) {
|
||||
clientT, daemonT := protocol.NewLoopbackPair()
|
||||
inR, inW := ioPipe(t)
|
||||
out := &lockedBuffer{}
|
||||
|
||||
gotInput := make(chan protocol.Input, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
f, err := daemonT.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if f.Type != protocol.FrameAttach {
|
||||
t.Errorf("first frame = %s, want attach", f.Type)
|
||||
errCh <- nil
|
||||
return
|
||||
}
|
||||
sendTestFrame(t, daemonT, protocol.FrameHello, protocol.Hello{Version: 1, ClientID: "test", ProjectKey: "project"})
|
||||
sendTestFrame(t, daemonT, protocol.FrameProjectList, protocol.ProjectList{})
|
||||
model := chromeModel{
|
||||
ProjectKey: "project",
|
||||
FocusedID: "p1",
|
||||
Processes: []childModel{{ID: "p1", Name: "shell", Kind: string(KindCommand), Status: string(StatusRunning)}},
|
||||
Sidebar: []navEntryModel{{ChildID: "p1"}},
|
||||
}
|
||||
sendTestFrame(t, daemonT, protocol.FrameChrome, protocol.Chrome{ProjectKey: "project", Model: mustMarshalTest(t, model)})
|
||||
sendTestFrame(t, daemonT, protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: "p1", Bytes: []byte("READY")})
|
||||
for {
|
||||
f, err := daemonT.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if f.Type != protocol.FrameInput {
|
||||
continue
|
||||
}
|
||||
input, err := protocol.Decode[protocol.Input](f)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
gotInput <- input
|
||||
_ = daemonT.Close()
|
||||
errCh <- nil
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
runCh := make(chan error, 1)
|
||||
go func() {
|
||||
runCh <- RunAttachedClient(ctx, ClientOptions{
|
||||
Transport: clientT,
|
||||
Stdin: inR,
|
||||
Stdout: out,
|
||||
Cols: 80,
|
||||
Rows: 24,
|
||||
})
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) && !bytes.Contains(out.Bytes(), []byte("READY")) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !bytes.Contains(out.Bytes(), []byte("READY")) {
|
||||
t.Fatalf("snapshot was not rendered before input; output=%q", out.String())
|
||||
}
|
||||
if _, err := inW.Write([]byte("echo hi\r")); err != nil {
|
||||
t.Fatalf("write stdin: %v", err)
|
||||
}
|
||||
select {
|
||||
case input := <-gotInput:
|
||||
if input.PaneID != "p1" || string(input.Bytes) != "echo hi\r" {
|
||||
t.Fatalf("input = %#v", input)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("client did not forward input")
|
||||
}
|
||||
cancel()
|
||||
_ = inW.Close()
|
||||
select {
|
||||
case err := <-runCh:
|
||||
if err != nil {
|
||||
t.Fatalf("client run: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("client did not stop")
|
||||
}
|
||||
if err := <-errCh; err != nil && err != protocol.ErrTransportClosed {
|
||||
t.Fatalf("daemon side: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.b.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Bytes() []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]byte(nil), b.b.Bytes()...)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.b.String()
|
||||
}
|
||||
|
||||
func ioPipe(t *testing.T) (*io.PipeReader, *io.PipeWriter) {
|
||||
t.Helper()
|
||||
r, w := io.Pipe()
|
||||
return r, w
|
||||
}
|
||||
|
||||
func sendTestFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
|
||||
t.Helper()
|
||||
f, err := protocol.NewFrame(typ, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("frame %s: %v", typ, err)
|
||||
}
|
||||
if err := tr.Send(f); err != nil {
|
||||
t.Fatalf("send %s: %v", typ, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalTest(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
|
||||
if err != nil {
|
||||
t.Fatalf("vt emulator: %v", err)
|
||||
}
|
||||
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
|
||||
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
|
||||
if err != nil {
|
||||
_ = em.Close()
|
||||
t.Fatalf("pty start: %v", err)
|
||||
|
||||
@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
|
||||
p, err := pkgpty.Start([]string{env.PattermBin, "--in-process", "--project", env.ProjectDir}, childEnv, "", env.Cols, env.Rows)
|
||||
if err != nil {
|
||||
_ = em.Close()
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user