// This file wires the remote-control bridge (internal/remotecontrol, #442) into // the full-screen TUI, the counterpart to internal/cli/repl/remotecontrol.go. // It adds the "/remote-control" command that starts/stops an in-process // HTTP+WebSocket server mirroring the session to a paired browser on the LAN, // mirrors transcript output to that browser, surfaces browser-submitted prompts // as a tea.Msg, and routes side-effect tool-call confirmations to the browser // while a client is connected. // // The non-remote path is unchanged: when no session is active the mirror is a // no-op, waitRemoteInput returns a nil Cmd, and buildConfig installs no // BeforeToolCall (tools run under the up-front trust the TUI already grants). package tui import ( "context" "fmt" "strings" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/remotecontrol" "github.com/smallnest/pigo/internal/trust" ) // remoteSession owns the running server + bridge for one /remote-control // activation. It is stored on runSession (so buildConfig can reach it to install // the confirm seam) and is nil until the command starts a session. type remoteSession struct { server *remotecontrol.Server bridge *remotecontrol.Bridge url string } // hasClient reports whether a browser is currently paired and connected. func (rs *remoteSession) hasClient() bool { return rs != nil && rs.bridge != nil && rs.bridge.Enabled() } // sendOutput mirrors session text to the remote browser. It records into the // server's replay ring even when no client is connected, so a browser that pairs // mid-session is replayed the recent scrollback. func (rs *remoteSession) sendOutput(text string) { if rs == nil || rs.server == nil || text == "" { return } rs.server.SendOutput(text) } // startRemote builds and starts the server+bridge, storing the session on the // runSession. It returns the pairing URL, or an error if a server is already // running or the listener could not bind. func (s *runSession) startRemote() (string, error) { if s.remote != nil { return s.remote.url, fmt.Errorf("already running") } // Break the server↔bridge construction cycle: build the server (Sink), then // the bridge over it, then route client frames back to the bridge. srv := remotecontrol.NewServer(remotecontrol.Config{}, nil) bridge := remotecontrol.NewBridge(srv) srv.SetHandler(bridge) url, err := srv.Start() if err != nil { return "", err } s.remote = &remoteSession{server: srv, bridge: bridge, url: url} return url, nil } // stopRemote shuts down the running server and clears the session. It is a no-op // when remote control is off. func (s *runSession) stopRemote() { if s.remote == nil { return } _ = s.remote.server.Stop(context.Background()) s.remote = nil } // remoteConfirmSeam builds the BeforeToolCall seam that routes side-effect // tool-call confirmations to the paired browser while one is connected. When no // browser is connected (or the tool is not side-effecting, or the cwd is // trusted) it returns nil so the tool runs under the up-front trust the TUI // grants — the non-remote behavior is unchanged. // // A ctx cancellation (interrupt) makes Confirm return remote=false, which is // treated as a denial so an interrupted run does not silently proceed. func remoteConfirmSeam(rs *remoteSession, mgr *trust.Manager, cwd string) agentcore.BeforeToolCallFunc { return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision { if !rs.hasClient() || mgr == nil { return nil } if !trust.SideEffectTools[call.Name] { return nil } if mgr.IsTrusted(cwd) { return nil } summary := trust.ToolCallSummary(call) d, remote := rs.bridge.Confirm(ctx, call.Name, summary) if !remote { return blockRemoteToolCall(call, cwd) } if d.Always { mgr.SetSessionTrust(cwd) } if !d.Approve { return blockRemoteToolCall(call, cwd) } return nil } } func blockRemoteToolCall(call agentcore.AgentToolCall, cwd string) *agentcore.BeforeToolCallDecision { msg := fmt.Sprintf("tool %q blocked: %s is not trusted (use /trust to trust this project)", call.Name, cwd) return &agentcore.BeforeToolCallDecision{ Block: true, Content: &agentcore.ContentList{agentcore.NewTextContent(msg)}, } } // runRemoteControl handles the /remote-control command and its stop/status // subcommands. It mutates m.session.remote, folds a system block into the // transcript, and returns the listener Cmd (waitRemoteInput) on a successful // start so browser-submitted prompts begin arriving. func (m Model) runRemoteControl(line string) (tea.Model, tea.Cmd) { m.transcript.addUser(line) m.input.Clear() m.menu.close() defer m.relayout() if m.session == nil { m.transcript.addSystem("(remote control unavailable: no active session)") return m, nil } arg := strings.TrimSpace(strings.TrimPrefix(line, "/remote-control")) switch arg { case "stop": if m.session.remote == nil { m.transcript.addSystem("remote control is not running") return m, nil } m.session.stopRemote() m.transcript.addSystem("remote control stopped") return m, nil case "status": if m.session.remote == nil { m.transcript.addSystem("remote control: off") return m, nil } state := "waiting for a browser to connect" if m.session.remote.hasClient() { state = "browser connected" } m.transcript.addSystem(fmt.Sprintf("remote control: on (%s)\n %s", state, m.session.remote.url)) return m, nil case "": if m.session.remote != nil { m.transcript.addSystem("remote control already running:\n " + m.session.remote.url) return m, nil } url, err := m.session.startRemote() if err != nil { m.transcript.addSystem("remote control: " + err.Error()) return m, nil } var b strings.Builder fmt.Fprintf(&b, "Remote control started. Open this URL on a device on the same network:\n\n %s\n", url) if qr, qerr := remotecontrol.Render(url); qerr == nil { b.WriteString("\n" + qr) } b.WriteString("\nRun /remote-control stop to end the session.") m.transcript.addSystem(b.String()) return m, m.waitRemoteInput() default: m.transcript.addSystem("usage: /remote-control [stop|status]") return m, nil } } // remoteEcho mirrors visible transcript text to the paired browser. It is a // no-op when remote control is off, so callers can invoke it unconditionally at // each point the transcript gains content. func (m Model) remoteEcho(text string) { if m.session != nil && m.session.remote != nil { m.session.remote.sendOutput(text) } } // waitRemoteInput returns a tea.Cmd that blocks on the bridge's remote-input // channel and emits one remoteInputMsg per browser submission. The Update loop // re-issues it after each so successive prompts keep arriving. It returns nil // when remote control is off, which stops the listener. func (m Model) waitRemoteInput() tea.Cmd { if m.session == nil || m.session.remote == nil || m.session.remote.bridge == nil { return nil } ch := m.session.remote.bridge.RemoteInput() return func() tea.Msg { text, ok := <-ch if !ok { return nil } return remoteInputMsg{text: text} } }