207 lines
7.1 KiB
Go
207 lines
7.1 KiB
Go
// 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}
|
|
}
|
|
}
|