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
|
project listing, focused-pane snapshots, pane chunks, resize/focus
|
||||||
updates, and daemon-owned command spawn requests while keeping child
|
updates, and daemon-owned command spawn requests while keeping child
|
||||||
processes alive after a client disconnects.
|
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
|
- patterm can now keep multiple local projects loaded in one loopback
|
||||||
daemon core, with command-palette entries to switch the current
|
daemon core, with command-palette entries to switch the current
|
||||||
client view or open another project without tearing down processes
|
client view or open another project without tearing down processes
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func main() {
|
|||||||
var (
|
var (
|
||||||
projectDir = flag.String("project", "", "project directory (default $PWD)")
|
projectDir = flag.String("project", "", "project directory (default $PWD)")
|
||||||
showVersion = flag.Bool("version", false, "print version and exit")
|
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)")
|
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)")
|
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 != "" {
|
if *projectDir != "" {
|
||||||
cwd = *projectDir
|
cwd = *projectDir
|
||||||
|
} else if flag.NArg() > 0 {
|
||||||
|
cwd = flag.Arg(0)
|
||||||
}
|
}
|
||||||
key, err := projectkey.Key(cwd)
|
key, err := projectkey.Key(cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,11 +110,26 @@ func main() {
|
|||||||
defer stopProfile()
|
defer stopProfile()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
if err := app.Run(ctx, app.Options{
|
if *inProcess || os.Getenv("PATTERM_NO_DAEMON") != "" {
|
||||||
|
if err := app.Run(ctx, app.Options{
|
||||||
|
ProjectDir: cwd,
|
||||||
|
ProjectKey: key,
|
||||||
|
DebugDir: resolvedDebug,
|
||||||
|
ProfileDir: resolvedProfile,
|
||||||
|
}); 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,
|
ProjectDir: cwd,
|
||||||
ProjectKey: key,
|
Stdin: os.Stdin,
|
||||||
DebugDir: resolvedDebug,
|
Stdout: os.Stdout,
|
||||||
ProfileDir: resolvedProfile,
|
RawMode: true,
|
||||||
|
AutoStart: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
die("%v", err)
|
die("%v", err)
|
||||||
}
|
}
|
||||||
@@ -223,6 +241,8 @@ func runDaemonCommand() {
|
|||||||
}
|
}
|
||||||
if *projectDir != "" {
|
if *projectDir != "" {
|
||||||
cwd = *projectDir
|
cwd = *projectDir
|
||||||
|
} else if flag.NArg() > 0 {
|
||||||
|
cwd = flag.Arg(0)
|
||||||
}
|
}
|
||||||
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
|
if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil {
|
||||||
die("daemon: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("vt emulator: %v", err)
|
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 {
|
if err != nil {
|
||||||
_ = em.Close()
|
_ = em.Close()
|
||||||
t.Fatalf("pty start: %v", err)
|
t.Fatalf("pty start: %v", err)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func NewCLI(opts Options) (*Session, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
_ = em.Close()
|
_ = em.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user