first commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user